mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-18 16:54:25 +08:00
add insights & explore page
This commit is contained in:
@@ -44,6 +44,12 @@
|
||||
<span class="nav-item-title">{{ tt('Statistics & Analysis') }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-link">
|
||||
<router-link to="/insights/explore">
|
||||
<v-icon class="nav-item-icon" :icon="mdiCompassOutline"/>
|
||||
<span class="nav-item-title">{{ tt('Insights & Explore') }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-section-title">
|
||||
<div class="title-wrapper">
|
||||
<span class="title-text">{{ tt('Basis Data') }}</span>
|
||||
@@ -228,6 +234,7 @@ import {
|
||||
mdiClipboardTextOutline,
|
||||
mdiClipboardTextClockOutline,
|
||||
mdiChartPieOutline,
|
||||
mdiCompassOutline,
|
||||
mdiSwapHorizontal,
|
||||
mdiCogOutline,
|
||||
mdiCellphone,
|
||||
|
||||
@@ -224,6 +224,40 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-card :title="tt('Insights & Explore Page')">
|
||||
<v-form>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
item-title="displayName"
|
||||
item-value="type"
|
||||
persistent-placeholder
|
||||
:label="tt('Default Date Range')"
|
||||
:placeholder="tt('Default Date Range')"
|
||||
:items="allInsightsExploreDefaultDateRanges"
|
||||
v-model="insightsExploreDefaultDateRangeType"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
item-title="displayName"
|
||||
item-value="type"
|
||||
persistent-placeholder
|
||||
:label="tt('Timezone Used for Date Range')"
|
||||
:placeholder="tt('Timezone Used for Date Range')"
|
||||
:items="allTimezoneTypesUsedForStatistics"
|
||||
v-model="timezoneUsedForInsightsExplorePage"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-card :title="tt('Account List Page')">
|
||||
<v-form>
|
||||
@@ -305,9 +339,11 @@ import { useAppSettingPageBase } from '@/views/base/settings/AppSettingsPageBase
|
||||
import { useSettingsStore } from '@/stores/setting.ts';
|
||||
import { useAccountsStore } from '@/stores/account.ts';
|
||||
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts';
|
||||
import { useExploresStore } from '@/stores/explore.ts';
|
||||
|
||||
import type { LocalizedSwitchOption } from '@/core/base.ts';
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
import { type LocalizedDateRange, DateRangeScene } from '@/core/datetime.ts';
|
||||
import { CategoryType } from '@/core/category.ts';
|
||||
|
||||
import { getSystemTheme } from '@/lib/ui/common.ts';
|
||||
@@ -316,7 +352,7 @@ type SnackBarType = InstanceType<typeof SnackBar>;
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const { tt, getAllEnableDisableOptions } = useI18n();
|
||||
const { tt, getAllEnableDisableOptions, getAllDateRanges } = useI18n();
|
||||
const {
|
||||
loadingAccounts,
|
||||
loadingTransactionCategories,
|
||||
@@ -347,6 +383,7 @@ const {
|
||||
const settingsStore = useSettingsStore();
|
||||
const accountsStore = useAccountsStore();
|
||||
const transactionCategoriesStore = useTransactionCategoriesStore();
|
||||
const exploresStore = useExploresStore();
|
||||
|
||||
const snackbar = useTemplateRef<SnackBarType>('snackbar');
|
||||
|
||||
@@ -355,6 +392,7 @@ const showTransactionCategoriesIncludedInHomePageOverviewDialog = ref<boolean>(f
|
||||
const showAccountsIncludedInTotalDialog = ref<boolean>(false);
|
||||
|
||||
const enableDisableOptions = computed<LocalizedSwitchOption[]>(() => getAllEnableDisableOptions());
|
||||
const allInsightsExploreDefaultDateRanges = computed<LocalizedDateRange[]>(() => getAllDateRanges(DateRangeScene.InsightsExplore, false));
|
||||
|
||||
const currentTheme = computed<string>({
|
||||
get: () => settingsStore.appSettings.theme,
|
||||
@@ -376,6 +414,19 @@ const showAddTransactionButtonInDesktopNavbar = computed<boolean>({
|
||||
set: (value) => settingsStore.setShowAddTransactionButtonInDesktopNavbar(value)
|
||||
});
|
||||
|
||||
const insightsExploreDefaultDateRangeType = computed<number>({
|
||||
get: () => settingsStore.appSettings.insightsExploreDefaultDateRangeType,
|
||||
set: (value) => settingsStore.setInsightsExploreDefaultDateRangeType(value)
|
||||
});
|
||||
|
||||
const timezoneUsedForInsightsExplorePage = computed<number>({
|
||||
get: () => settingsStore.appSettings.timezoneUsedForInsightsExplorePage,
|
||||
set: (value: number) => {
|
||||
settingsStore.setTimezoneUsedForInsightsExplorePage(value);
|
||||
exploresStore.updateTransactionExploreInvalidState(true);
|
||||
}
|
||||
});
|
||||
|
||||
function init(): void {
|
||||
loadingAccounts.value = true;
|
||||
|
||||
|
||||
@@ -162,12 +162,13 @@ type SnackBarType = InstanceType<typeof SnackBar>;
|
||||
|
||||
const props = defineProps<{
|
||||
type: AccountFilterType;
|
||||
selectedAccountIds?: string[];
|
||||
dialogMode?: boolean;
|
||||
autoSave?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'settings:change', changed: boolean): void;
|
||||
(e: 'settings:change', changed: boolean, selectedAccountIds?: string[]): void;
|
||||
}>();
|
||||
|
||||
const { tt } = useI18n();
|
||||
@@ -187,7 +188,7 @@ const {
|
||||
isAccountChecked,
|
||||
loadFilterAccountIds,
|
||||
saveFilterAccountIds
|
||||
} = useAccountFilterSettingPageBase(props.type);
|
||||
} = useAccountFilterSettingPageBase(props.type, props.selectedAccountIds);
|
||||
|
||||
const accountsStore = useAccountsStore();
|
||||
|
||||
@@ -254,8 +255,8 @@ function selectInvertAccounts(): void {
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
const changed = saveFilterAccountIds();
|
||||
emit('settings:change', changed);
|
||||
const [changed, selectedAccountIds] = saveFilterAccountIds();
|
||||
emit('settings:change', changed, selectedAccountIds);
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
|
||||
@@ -160,13 +160,14 @@ type SnackBarType = InstanceType<typeof SnackBar>;
|
||||
|
||||
const props = defineProps<{
|
||||
type: CategoryFilterType;
|
||||
selectedCategoryIds?: string[];
|
||||
dialogMode?: boolean;
|
||||
autoSave?: boolean;
|
||||
categoryTypes?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'settings:change', changed: boolean): void;
|
||||
(e: 'settings:change', changed: boolean, selectedCategoryIds?: string[]): void;
|
||||
}>();
|
||||
|
||||
const { tt } = useI18n();
|
||||
@@ -186,7 +187,7 @@ const {
|
||||
getCategoryTypeName,
|
||||
loadFilterCategoryIds,
|
||||
saveFilterCategoryIds
|
||||
} = useCategoryFilterSettingPageBase(props.type, props.categoryTypes);
|
||||
} = useCategoryFilterSettingPageBase(props.type, props.categoryTypes, props.selectedCategoryIds);
|
||||
|
||||
const transactionCategoriesStore = useTransactionCategoriesStore();
|
||||
|
||||
@@ -261,8 +262,8 @@ function selectInvertCategories(): void {
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
const changed = saveFilterCategoryIds();
|
||||
emit('settings:change', changed);
|
||||
const [changed, selectedCategoryIds] = saveFilterCategoryIds();
|
||||
emit('settings:change', changed, selectedCategoryIds);
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<v-row class="match-height">
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-layout>
|
||||
<v-navigation-drawer :permanent="alwaysShowNav" v-model="showNav">
|
||||
<div class="mx-6 my-4">
|
||||
<btn-vertical-group :disabled="loading" :buttons="allTabs" v-model="activeTab" />
|
||||
</div>
|
||||
<v-divider />
|
||||
<div class="mx-6 mt-4" v-if="activeTab === 'table'">
|
||||
<span class="text-subtitle-2">{{ tt('Transactions Per Page') }}</span>
|
||||
<v-select class="mt-2" density="compact"
|
||||
item-title="name"
|
||||
item-value="value"
|
||||
:disabled="loading"
|
||||
:items="allPageCounts"
|
||||
v-model="countPerPage"
|
||||
/>
|
||||
</div>
|
||||
<v-tabs show-arrows class="my-4" direction="vertical"
|
||||
:disabled="loading" v-model="currentExploreId">
|
||||
<v-tab class="tab-text-truncate" key="new" value="">
|
||||
<span class="text-truncate">{{ tt('New Explore') }}</span>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
</v-navigation-drawer>
|
||||
<v-main>
|
||||
<v-card variant="flat" min-height="800">
|
||||
<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('Insights & Explore') }}</span>
|
||||
<v-btn-group class="ms-4" color="default" density="comfortable" variant="outlined" divided>
|
||||
<v-btn class="button-icon-with-direction" :icon="mdiArrowLeft"
|
||||
:disabled="loading || !canShiftDateRange"
|
||||
@click="shiftDateRange(-1)"/>
|
||||
<v-menu location="bottom" max-height="500">
|
||||
<template #activator="{ props }">
|
||||
<v-btn :disabled="loading"
|
||||
v-bind="props">{{ displayQueryDateRangeName }}</v-btn>
|
||||
</template>
|
||||
<v-list :selected="[query.dateRangeType]">
|
||||
<v-list-item :key="dateRange.type" :value="dateRange.type"
|
||||
:append-icon="(query.dateRangeType === dateRange.type ? mdiCheck : undefined)"
|
||||
v-for="dateRange in allDateRanges">
|
||||
<v-list-item-title class="cursor-pointer"
|
||||
@click="setDateFilter(dateRange.type)">
|
||||
<div class="d-flex align-center">
|
||||
<span>{{ dateRange.displayName }}</span>
|
||||
</div>
|
||||
<div class="statistics-custom-datetime-range smaller" v-if="dateRange.isUserCustomRange && query.dateRangeType === dateRange.type && !!query.startTime && !!query.endTime">
|
||||
<span>{{ displayQueryStartTime }}</span>
|
||||
<span> - </span>
|
||||
<br/>
|
||||
<span>{{ displayQueryEndTime }}</span>
|
||||
</div>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn class="button-icon-with-direction" :icon="mdiArrowRight"
|
||||
:disabled="loading || !canShiftDateRange"
|
||||
@click="shiftDateRange(1)"/>
|
||||
</v-btn-group>
|
||||
|
||||
<v-btn density="compact" color="default" variant="text" size="24"
|
||||
class="ms-2" :icon="true" :loading="loading" @click="reload(true)">
|
||||
<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" :icon="true"
|
||||
v-if="activeTab !== 'query'">
|
||||
<v-icon :icon="mdiDotsVertical" />
|
||||
<v-menu activator="parent">
|
||||
<v-list>
|
||||
<v-list-item :prepend-icon="mdiExport"
|
||||
:title="tt('Export Results')"
|
||||
:disabled="loading || !filteredTransactions || filteredTransactions.length < 1"
|
||||
@click="exportResults"></v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-window class="d-flex flex-grow-1 disable-tab-transition w-100-window-container" v-model="activeTab">
|
||||
<v-window-item value="query">
|
||||
<explore-query-tab :loading="loading" />
|
||||
</v-window-item>
|
||||
<v-window-item value="table">
|
||||
<explore-data-table-tab ref="exploreDataTableTab"
|
||||
:loading="loading"
|
||||
v-model:count-per-page="countPerPage" />
|
||||
</v-window-item>
|
||||
<v-window-item value="chart">
|
||||
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card>
|
||||
</v-main>
|
||||
</v-layout>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<date-range-selection-dialog :title="tt('Custom Date Range')"
|
||||
:min-time="query.startTime"
|
||||
:max-time="query.endTime"
|
||||
v-model:show="showCustomDateRangeDialog"
|
||||
@dateRange:change="setCustomDateFilter"
|
||||
@error="onShowDateRangeError" />
|
||||
|
||||
<export-dialog ref="exportDialog" />
|
||||
|
||||
<snack-bar ref="snackbar" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExploreQueryTab from '@/views/desktop/insights/tabs/ExploreQueryTab.vue';
|
||||
import ExploreDataTableTab from '@/views/desktop/insights/tabs/ExploreDataTableTab.vue';
|
||||
import ExportDialog from '@/views/desktop/statistics/transaction/dialogs/ExportDialog.vue';
|
||||
import SnackBar from '@/components/desktop/SnackBar.vue';
|
||||
|
||||
import { ref, computed, useTemplateRef, watch } from 'vue';
|
||||
import { useRouter, onBeforeRouteUpdate } from 'vue-router';
|
||||
import { useDisplay } from 'vuetify';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
import { useAccountsStore } from '@/stores/account.ts';
|
||||
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts';
|
||||
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
|
||||
import { type TransactionExplorePartialFilter, type TransactionExploreFilter, useExploresStore } from '@/stores/explore.ts';
|
||||
|
||||
import type { NameNumeralValue } from '@/core/base.ts';
|
||||
import type { NumeralSystem } from '@/core/numeral.ts';
|
||||
import { type WeekDayValue, type LocalizedDateRange, DateRangeScene, DateRange } from '@/core/datetime.ts';
|
||||
|
||||
import {
|
||||
type TransactionInsightDataItem
|
||||
} from '@/models/transaction.ts';
|
||||
|
||||
import {
|
||||
getShiftedDateRangeAndDateType,
|
||||
getDateTypeByDateRange,
|
||||
getDateRangeByDateType,
|
||||
} from '@/lib/datetime.ts';
|
||||
|
||||
import {
|
||||
mdiMenu,
|
||||
mdiArrowLeft,
|
||||
mdiArrowRight,
|
||||
mdiCheck,
|
||||
mdiRefresh,
|
||||
mdiDotsVertical,
|
||||
mdiExport
|
||||
} from '@mdi/js';
|
||||
|
||||
interface InsightsExploreProps {
|
||||
initId?: string;
|
||||
initActiveTab?: string,
|
||||
initDateRangeType?: string,
|
||||
initStartTime?: string,
|
||||
initEndTime?: string,
|
||||
}
|
||||
|
||||
const props = defineProps<InsightsExploreProps>();
|
||||
|
||||
type ExplorePageTabType = 'query' | 'table' | 'chart';
|
||||
type SnackBarType = InstanceType<typeof SnackBar>;
|
||||
type ExploreDataTableTabType = InstanceType<typeof ExploreDataTableTab>;
|
||||
type ExportDialogType = InstanceType<typeof ExportDialog>;
|
||||
|
||||
const router = useRouter();
|
||||
const display = useDisplay();
|
||||
|
||||
const {
|
||||
tt,
|
||||
getAllDateRanges,
|
||||
getCurrentNumeralSystemType,
|
||||
formatUnixTimeToLongDateTime,
|
||||
formatDateRange
|
||||
} = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const accountsStore = useAccountsStore();
|
||||
const transactionCategoriesStore = useTransactionCategoriesStore();
|
||||
const transactionTagsStore = useTransactionTagsStore();
|
||||
const exploresStore = useExploresStore();
|
||||
|
||||
const snackbar = useTemplateRef<SnackBarType>('snackbar');
|
||||
const exploreDataTableTab = useTemplateRef<ExploreDataTableTabType>('exploreDataTableTab');
|
||||
const exportDialog = useTemplateRef<ExportDialogType>('exportDialog');
|
||||
|
||||
const loading = ref<boolean>(true);
|
||||
const initing = ref<boolean>(true);
|
||||
const alwaysShowNav = ref<boolean>(display.mdAndUp.value);
|
||||
const showNav = ref<boolean>(display.mdAndUp.value);
|
||||
const activeTab = ref<ExplorePageTabType>('query');
|
||||
const currentExploreId = ref<string>('');
|
||||
const countPerPage = ref<number>(15);
|
||||
const showCustomDateRangeDialog = ref<boolean>(false);
|
||||
|
||||
const firstDayOfWeek = computed<WeekDayValue>(() => userStore.currentUserFirstDayOfWeek);
|
||||
const fiscalYearStart = computed<number>(() => userStore.currentUserFiscalYearStart);
|
||||
const numeralSystem = computed<NumeralSystem>(() => getCurrentNumeralSystemType());
|
||||
|
||||
const query = computed<TransactionExploreFilter>(() => exploresStore.transactionExploreFilter);
|
||||
const filteredTransactions = computed<TransactionInsightDataItem[]>(() => exploresStore.filteredTransactions);
|
||||
|
||||
const allDateRanges = computed<LocalizedDateRange[]>(() => getAllDateRanges(DateRangeScene.InsightsExplore, true));
|
||||
const canShiftDateRange = computed<boolean>(() => query.value.dateRangeType !== DateRange.All.type);
|
||||
const displayQueryDateRangeName = computed<string>(() => formatDateRange(query.value.dateRangeType, query.value.startTime, query.value.endTime));
|
||||
const displayQueryStartTime = computed<string>(() => formatUnixTimeToLongDateTime(query.value.startTime));
|
||||
const displayQueryEndTime = computed<string>(() => formatUnixTimeToLongDateTime(query.value.endTime));
|
||||
|
||||
const allTabs = computed<{ name: string, value: ExplorePageTabType }[]>(() => {
|
||||
return [
|
||||
{
|
||||
name: tt('Query'),
|
||||
value: 'query'
|
||||
},
|
||||
{
|
||||
name: tt('Data Table'),
|
||||
value: 'table'
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
const allPageCounts = computed<NameNumeralValue[]>(() => {
|
||||
const pageCounts: NameNumeralValue[] = [];
|
||||
const availableCountPerPage: number[] = [ 5, 10, 15, 20, 25, 30, 50 ];
|
||||
|
||||
for (const count of availableCountPerPage) {
|
||||
pageCounts.push({ value: count, name: numeralSystem.value.formatNumber(count) });
|
||||
}
|
||||
|
||||
pageCounts.push({ value: -1, name: tt('All') });
|
||||
|
||||
return pageCounts;
|
||||
});
|
||||
|
||||
function getFilterLinkUrl(): string {
|
||||
return `/insights/explore?${exploresStore.getTransactionExplorePageParams(currentExploreId.value, activeTab.value)}`;
|
||||
}
|
||||
|
||||
function init(initProps: InsightsExploreProps): void {
|
||||
const filter: TransactionExplorePartialFilter = {
|
||||
dateRangeType: initProps.initDateRangeType ? parseInt(initProps.initDateRangeType) : undefined,
|
||||
startTime: initProps.initStartTime ? parseInt(initProps.initStartTime) : undefined,
|
||||
endTime: initProps.initEndTime ? parseInt(initProps.initEndTime) : undefined
|
||||
};
|
||||
|
||||
let needReload = false;
|
||||
|
||||
if (filter.dateRangeType !== query.value.dateRangeType) {
|
||||
needReload = true;
|
||||
} else if (filter.dateRangeType === DateRange.Custom.type) {
|
||||
if (filter.startTime !== query.value.startTime
|
||||
|| filter.endTime !== query.value.endTime) {
|
||||
needReload = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (initProps.initActiveTab === 'query' || initProps.initActiveTab === 'table' || initProps.initActiveTab === 'chart') {
|
||||
if (initProps.initActiveTab !== activeTab.value) {
|
||||
activeTab.value = initProps.initActiveTab;
|
||||
needReload = true;
|
||||
}
|
||||
} else {
|
||||
activeTab.value = 'query';
|
||||
}
|
||||
|
||||
exploresStore.initTransactionExploreFilter(filter);
|
||||
|
||||
if (!needReload && !exploresStore.transactionExploreStateInvalid) {
|
||||
loading.value = false;
|
||||
initing.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
accountsStore.loadAllAccounts({ force: false }),
|
||||
transactionCategoriesStore.loadAllCategories({ force: false }),
|
||||
transactionTagsStore.loadAllTags({ force: false })
|
||||
]).then(() => {
|
||||
return exploresStore.loadAllTransactions({ force: false });
|
||||
}).then(() => {
|
||||
loading.value = false;
|
||||
initing.value = false;
|
||||
}).catch(error => {
|
||||
loading.value = false;
|
||||
initing.value = false;
|
||||
|
||||
if (!error.processed) {
|
||||
snackbar.value?.showError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reload(force: boolean): Promise<unknown> | null {
|
||||
loading.value = true;
|
||||
|
||||
return exploresStore.loadAllTransactions({
|
||||
force: force
|
||||
}).then(() => {
|
||||
loading.value = false;
|
||||
|
||||
if (force) {
|
||||
snackbar.value?.showMessage('Data has been updated');
|
||||
}
|
||||
}).catch(error => {
|
||||
loading.value = false;
|
||||
|
||||
if (!error.processed) {
|
||||
snackbar.value?.showError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function exportResults(): void {
|
||||
if (activeTab.value === 'table' && filteredTransactions.value) {
|
||||
const results = exploreDataTableTab.value?.buildExportResults();
|
||||
|
||||
if (results) {
|
||||
exportDialog.value?.open(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDateFilter(dateType: number): void {
|
||||
if (dateType === DateRange.Custom.type) { // Custom
|
||||
showCustomDateRangeDialog.value = true;
|
||||
return;
|
||||
} else if (query.value.dateRangeType === dateType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dateRange = getDateRangeByDateType(dateType, firstDayOfWeek.value, fiscalYearStart.value);
|
||||
|
||||
if (!dateRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = exploresStore.updateTransactionExploreFilter({
|
||||
dateRangeType: dateRange.dateType,
|
||||
startTime: dateRange.minTime,
|
||||
endTime: dateRange.maxTime
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
loading.value = true;
|
||||
exploresStore.updateTransactionExploreInvalidState(true);
|
||||
router.push(getFilterLinkUrl());
|
||||
}
|
||||
}
|
||||
|
||||
function setCustomDateFilter(startTime: number, endTime: number): void {
|
||||
if (!startTime || !endTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chartDateType = getDateTypeByDateRange(startTime, endTime, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.InsightsExplore);
|
||||
|
||||
const changed = exploresStore.updateTransactionExploreFilter({
|
||||
dateRangeType: chartDateType,
|
||||
startTime: startTime,
|
||||
endTime: endTime
|
||||
});
|
||||
|
||||
showCustomDateRangeDialog.value = false;
|
||||
|
||||
if (changed) {
|
||||
loading.value = true;
|
||||
exploresStore.updateTransactionExploreInvalidState(true);
|
||||
router.push(getFilterLinkUrl());
|
||||
}
|
||||
}
|
||||
|
||||
function shiftDateRange(scale: number): void {
|
||||
if (query.value.dateRangeType === DateRange.All.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDateRange = getShiftedDateRangeAndDateType(query.value.startTime, query.value.endTime, scale, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal);
|
||||
|
||||
const changed = exploresStore.updateTransactionExploreFilter({
|
||||
dateRangeType: newDateRange.dateType,
|
||||
startTime: newDateRange.minTime,
|
||||
endTime: newDateRange.maxTime
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
loading.value = true;
|
||||
exploresStore.updateTransactionExploreInvalidState(true);
|
||||
router.push(getFilterLinkUrl());
|
||||
}
|
||||
}
|
||||
|
||||
function onShowDateRangeError(message: string): void {
|
||||
snackbar.value?.showError(message);
|
||||
}
|
||||
|
||||
onBeforeRouteUpdate((to) => {
|
||||
if (to.query) {
|
||||
init({
|
||||
initId: (to.query['id'] as string | null) || undefined,
|
||||
initActiveTab: (to.query['activeTab'] as string | null) || undefined,
|
||||
initDateRangeType: (to.query['dateRangeType'] as string | null) || undefined,
|
||||
initStartTime: (to.query['startTime'] as string | null) || undefined,
|
||||
initEndTime: (to.query['endTime'] as string | null) || undefined,
|
||||
});
|
||||
} else {
|
||||
init({});
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => display.mdAndUp.value, (newValue) => {
|
||||
alwaysShowNav.value = newValue;
|
||||
|
||||
if (!showNav.value) {
|
||||
showNav.value = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
watch(activeTab, () => {
|
||||
router.push(getFilterLinkUrl());
|
||||
});
|
||||
|
||||
init(props);
|
||||
</script>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
fixed-header
|
||||
fixed-footer
|
||||
multi-sort
|
||||
item-value="index"
|
||||
:class="{ 'insights-explore-table': true, 'text-sm': true, 'disabled': loading, 'loading-skeleton': loading }"
|
||||
:headers="dataTableHeaders"
|
||||
:items="filteredTransactions"
|
||||
:hover="true"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
v-model:page="currentPage"
|
||||
>
|
||||
<template #item.time="{ item }">
|
||||
<span>{{ getDisplayDateTime(item) }}</span>
|
||||
<v-chip class="ms-1" variant="flat" color="secondary" size="x-small"
|
||||
v-if="item.utcOffset !== currentTimezoneOffsetMinutes">{{ getDisplayTimezone(item) }}</v-chip>
|
||||
</template>
|
||||
<template #item.type="{ item }">
|
||||
<v-chip label variant="outlined" size="x-small"
|
||||
:class="{ 'text-income' : item.type === TransactionType.Income, 'text-expense': item.type === TransactionType.Expense }"
|
||||
:color="getTransactionTypeColor(item)">{{ getDisplayTransactionType(item) }}</v-chip>
|
||||
</template>
|
||||
<template #item.secondaryCategoryName="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<ItemIcon size="24px" icon-type="category"
|
||||
:icon-id="item.secondaryCategory?.icon ?? ''"
|
||||
:color="item.secondaryCategory?.color ?? ''"
|
||||
v-if="item.secondaryCategory?.color"></ItemIcon>
|
||||
<v-icon size="24" :icon="mdiPencilBoxOutline" v-else-if="!item.secondaryCategory || !item.secondaryCategory?.color" />
|
||||
<span class="ms-2" v-if="item.type === TransactionType.ModifyBalance">
|
||||
{{ tt('Modify Balance') }}
|
||||
</span>
|
||||
<span class="ms-2" v-else-if="item.type !== TransactionType.ModifyBalance && item.secondaryCategory">
|
||||
{{ item.secondaryCategory?.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item.sourceAmount="{ item }">
|
||||
<span :class="{ 'text-expense': item.type === TransactionType.Expense, 'text-income': item.type === TransactionType.Income }">{{ getDisplaySourceAmount(item) }}</span>
|
||||
<v-icon class="icon-with-direction mx-1" size="13" :icon="mdiArrowRight" v-if="item.type === TransactionType.Transfer && item.sourceAccount?.id !== item.destinationAccount?.id && getDisplaySourceAmount(item) !== getDisplayDestinationAmount(item)"></v-icon>
|
||||
<span v-if="item.type === TransactionType.Transfer && item.sourceAccount?.id !== item.destinationAccount?.id && getDisplaySourceAmount(item) !== getDisplayDestinationAmount(item)">{{ getDisplayDestinationAmount(item) }}</span>
|
||||
</template>
|
||||
<template #item.sourceAccountName="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<span v-if="item.sourceAccount">{{ item.sourceAccount?.name }}</span>
|
||||
<v-icon class="icon-with-direction mx-1" size="13" :icon="mdiArrowRight" v-if="item.type === TransactionType.Transfer"></v-icon>
|
||||
<span v-if="item.type === TransactionType.Transfer && item.destinationAccount">{{ item.destinationAccount?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #no-data>
|
||||
<div v-if="loading && (!filteredTransactions || filteredTransactions.length < 1)">
|
||||
<div class="ms-1" style="padding-top: 3px; padding-bottom: 3px" :key="itemIdx" v-for="itemIdx in skeletonData">
|
||||
<v-skeleton-loader type="text" :loading="true"></v-skeleton-loader>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ tt('No transaction data') }}
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="title-and-toolbar d-flex align-center justify-center text-no-wrap mt-2 mb-4">
|
||||
<pagination-buttons :disabled="loading"
|
||||
:totalPageCount="totalPageCount"
|
||||
v-model="currentPage">
|
||||
</pagination-buttons>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PaginationButtons from '@/components/desktop/PaginationButtons.vue';
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { useSettingsStore } from '@/stores/setting.ts';
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
import { useExploresStore } from '@/stores/explore.ts';
|
||||
|
||||
import { TransactionType } from '@/core/transaction.ts';
|
||||
|
||||
import {
|
||||
type TransactionInsightDataItem
|
||||
} from '@/models/transaction.ts';
|
||||
|
||||
import {
|
||||
getUtcOffsetByUtcOffsetMinutes,
|
||||
getTimezoneOffsetMinutes,
|
||||
parseDateTimeFromUnixTime
|
||||
} from '@/lib/datetime.ts';
|
||||
|
||||
import {
|
||||
mdiArrowRight,
|
||||
mdiPencilBoxOutline
|
||||
} from '@mdi/js';
|
||||
|
||||
interface InsightsExploreDataTableTabProps {
|
||||
loading?: boolean;
|
||||
countPerPage: number;
|
||||
}
|
||||
|
||||
const props = defineProps<InsightsExploreDataTableTabProps>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:countPerPage', value: number): void;
|
||||
}>();
|
||||
|
||||
const {
|
||||
tt,
|
||||
formatUnixTimeToLongDateTime,
|
||||
formatUnixTimeToGregorianDefaultDateTime,
|
||||
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
|
||||
formatAmountToLocalizedNumeralsWithCurrency
|
||||
} = useI18n();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
const exploresStore = useExploresStore();
|
||||
|
||||
const currentPage = ref<number>(1);
|
||||
|
||||
const currentTimezoneOffsetMinutes = computed<number>(() => getTimezoneOffsetMinutes(settingsStore.appSettings.timeZone));
|
||||
const defaultCurrency = computed<string>(() => userStore.currentUserDefaultCurrency);
|
||||
|
||||
const filteredTransactions = computed<TransactionInsightDataItem[]>(() => exploresStore.filteredTransactions);
|
||||
|
||||
const itemsPerPage = computed<number>({
|
||||
get: () => props.countPerPage,
|
||||
set: (value: number) => emit('update:countPerPage', value)
|
||||
})
|
||||
|
||||
const skeletonData = computed<number[]>(() => {
|
||||
const data: number[] = [];
|
||||
|
||||
for (let i = 0; i < itemsPerPage.value; i++) {
|
||||
data.push(i);
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
const totalPageCount = computed<number>(() => {
|
||||
if (!filteredTransactions.value || filteredTransactions.value.length < 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const count = filteredTransactions.value.length;
|
||||
return Math.ceil(count / itemsPerPage.value);
|
||||
});
|
||||
|
||||
const dataTableHeaders = computed<object[]>(() => {
|
||||
const headers: object[] = [];
|
||||
|
||||
headers.push({ key: 'time', value: 'time', title: tt('Transaction Time'), sortable: true, nowrap: true });
|
||||
headers.push({ key: 'type', value: 'type', title: tt('Type'), sortable: true, nowrap: true });
|
||||
headers.push({ key: 'secondaryCategoryName', value: 'secondaryCategoryName', title: tt('Category'), sortable: true, nowrap: true });
|
||||
headers.push({ key: 'sourceAmount', value: 'sourceAmount', title: tt('Amount'), sortable: true, nowrap: true });
|
||||
headers.push({ key: 'sourceAccountName', value: 'sourceAccountName', title: tt('Account'), sortable: true, nowrap: true });
|
||||
headers.push({ key: 'comment', value: 'comment', title: tt('Description'), sortable: true, nowrap: true });
|
||||
return headers;
|
||||
});
|
||||
|
||||
function getDisplayDateTime(transaction: TransactionInsightDataItem): string {
|
||||
return formatUnixTimeToLongDateTime(transaction.time, transaction.utcOffset, currentTimezoneOffsetMinutes.value);
|
||||
}
|
||||
|
||||
function getDisplayTimezone(transaction: TransactionInsightDataItem): string {
|
||||
return `UTC${getUtcOffsetByUtcOffsetMinutes(transaction.utcOffset)}`;
|
||||
}
|
||||
|
||||
function getDisplayTransactionType(transaction: TransactionInsightDataItem): string {
|
||||
if (transaction.type === TransactionType.ModifyBalance) {
|
||||
return tt('Modify Balance');
|
||||
} else if (transaction.type === TransactionType.Income) {
|
||||
return tt('Income');
|
||||
} else if (transaction.type === TransactionType.Expense) {
|
||||
return tt('Expense');
|
||||
} else if (transaction.type === TransactionType.Transfer) {
|
||||
return tt('Transfer');
|
||||
} else {
|
||||
return tt('Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
function getTransactionTypeColor(transaction: TransactionInsightDataItem): string | undefined {
|
||||
if (transaction.type === TransactionType.ModifyBalance) {
|
||||
return 'secondary';
|
||||
} else if (transaction.type === TransactionType.Income) {
|
||||
return undefined;
|
||||
} else if (transaction.type === TransactionType.Expense) {
|
||||
return undefined;
|
||||
} else if (transaction.type === TransactionType.Transfer) {
|
||||
return 'primary';
|
||||
} else {
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplaySourceAmount(transaction: TransactionInsightDataItem): string {
|
||||
let currency = defaultCurrency.value;
|
||||
|
||||
if (transaction.sourceAccount) {
|
||||
currency = transaction.sourceAccount.currency;
|
||||
}
|
||||
|
||||
return formatAmountToLocalizedNumeralsWithCurrency(transaction.sourceAmount, currency);
|
||||
}
|
||||
|
||||
function getDisplayDestinationAmount(transaction: TransactionInsightDataItem): string {
|
||||
let currency = defaultCurrency.value;
|
||||
|
||||
if (transaction.destinationAccount) {
|
||||
currency = transaction.destinationAccount.currency;
|
||||
}
|
||||
|
||||
return formatAmountToLocalizedNumeralsWithCurrency(transaction.destinationAmount, currency);
|
||||
}
|
||||
|
||||
function buildExportResults(): { headers: string[], data: string[][] } | undefined {
|
||||
if (!filteredTransactions.value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
headers: [
|
||||
tt('Transaction Time'),
|
||||
tt('Type'),
|
||||
tt('Category'),
|
||||
tt('Amount'),
|
||||
tt('Account'),
|
||||
tt('Description')
|
||||
],
|
||||
data: filteredTransactions.value
|
||||
.map(transaction => {
|
||||
const transactionTime = parseDateTimeFromUnixTime(transaction.time, transaction.utcOffset, currentTimezoneOffsetMinutes.value).getUnixTime();
|
||||
const type = getDisplayTransactionType(transaction);
|
||||
|
||||
let categoryName = transaction.secondaryCategoryName;
|
||||
let displayAmount = formatAmountToWesternArabicNumeralsWithoutDigitGrouping(transaction.sourceAmount);
|
||||
let displayAccountName = transaction.sourceAccountName;
|
||||
|
||||
if (transaction.type === TransactionType.ModifyBalance) {
|
||||
categoryName = tt('Modify Balance');
|
||||
} else if (transaction.type === TransactionType.Transfer && transaction.sourceAccount?.id !== transaction.destinationAccount?.id && getDisplaySourceAmount(transaction) !== getDisplayDestinationAmount(transaction)) {
|
||||
displayAmount = displayAmount + ' → ' + formatAmountToWesternArabicNumeralsWithoutDigitGrouping(transaction.destinationAmount);
|
||||
}
|
||||
|
||||
if (transaction.type === TransactionType.Transfer && transaction.destinationAccount) {
|
||||
displayAccountName = displayAccountName + ' → ' + (transaction.destinationAccount?.name || '');
|
||||
}
|
||||
|
||||
const description = transaction.comment || '';
|
||||
|
||||
return [
|
||||
formatUnixTimeToGregorianDefaultDateTime(transactionTime),
|
||||
type,
|
||||
categoryName,
|
||||
displayAmount,
|
||||
displayAccountName,
|
||||
description
|
||||
];
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
buildExportResults
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.v-table.insights-explore-table > .v-table__wrapper > table {
|
||||
th:not(:last-child),
|
||||
td:not(:last-child) {
|
||||
width: auto !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-table.insights-explore-table.loading-skeleton tr.v-data-table-rows-no-data > td {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,611 @@
|
||||
<template>
|
||||
<v-card-text class="pt-0">
|
||||
<div class="d-flex gap-2">
|
||||
<v-btn color="primary" variant="outlined"
|
||||
:disabled="loading"
|
||||
@click="addQuery">{{ tt('Add Query') }}</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn color="secondary" variant="tonal"
|
||||
:disabled="loading || queries.length < 1"
|
||||
@click="clearAllQueries">{{ tt('Clear All') }}</v-btn>
|
||||
</div>
|
||||
|
||||
<div :key="queryIndex" v-for="(query, queryIndex) in queries">
|
||||
<v-card class="mt-4" variant="outlined">
|
||||
<v-card-title class="d-flex align-center py-2 px-4">
|
||||
<span class="text-subtitle-1">{{ tt('Query') }} {{ `#${queryIndex + 1}` }}</span>
|
||||
<v-spacer />
|
||||
<v-switch class="bidirectional-switch ms-2" color="secondary"
|
||||
:label="tt('Expression')"
|
||||
v-model="showExpression"
|
||||
@click="showExpression = !showExpression">
|
||||
<template #prepend>
|
||||
<span>{{ tt('Editor') }}</span>
|
||||
</template>
|
||||
</v-switch>
|
||||
<v-btn class="ms-2" density="compact" color="default" variant="text" size="small"
|
||||
:icon="true" :disabled="loading || queries.length < 1"
|
||||
@click="removeQuery(queryIndex)">
|
||||
<v-icon :icon="mdiClose" size="18" />
|
||||
<v-tooltip activator="parent">{{ tt('Remove Query') }}</v-tooltip>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<div class="text-center py-4" v-if="!query.conditions || query.conditions.length < 1">
|
||||
{{ tt('No conditions defined. All transactions will match.') }}
|
||||
</div>
|
||||
|
||||
<div v-if="!showExpression">
|
||||
<div :key="conditionIndex" v-for="(conditionWithRelation, conditionIndex) in query.conditions">
|
||||
<div class="d-flex align-center gap-2 mb-4">
|
||||
<v-select
|
||||
disabled
|
||||
class="flex-0-0"
|
||||
width="120px"
|
||||
density="compact"
|
||||
item-title="displayName"
|
||||
item-value="value"
|
||||
:items="[{ value: TransactionExploreConditionRelation.First, displayName: tt('WHERE') }]"
|
||||
:model-value="TransactionExploreConditionRelation.First"
|
||||
v-if="conditionIndex < 1"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
class="flex-0-0"
|
||||
width="120px"
|
||||
density="compact"
|
||||
item-title="displayName"
|
||||
item-value="value"
|
||||
:disabled="loading"
|
||||
:items="[
|
||||
{ value: TransactionExploreConditionRelation.And, displayName: tt('AND') },
|
||||
{ value: TransactionExploreConditionRelation.Or, displayName: tt('OR') }
|
||||
]"
|
||||
v-model="conditionWithRelation.relation"
|
||||
v-else-if="conditionIndex >= 1"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
class="flex-0-0"
|
||||
density="compact"
|
||||
item-title="name"
|
||||
item-value="value"
|
||||
:disabled="loading"
|
||||
:items="allTransactionExploreConditionFields"
|
||||
@update:model-value="updateConditionField(queryIndex, conditionIndex, TransactionExploreConditionField.valueOf($event))"
|
||||
v-model="conditionWithRelation.condition.field"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
class="flex-0-0"
|
||||
density="compact"
|
||||
item-title="name"
|
||||
item-value="value"
|
||||
:disabled="loading"
|
||||
:items="getAllTransactionExploreConditionOperators(conditionWithRelation.getSupportedOperators())"
|
||||
v-model="conditionWithRelation.condition.operator"
|
||||
/>
|
||||
|
||||
<div class="d-flex w-100 flex-1-1">
|
||||
<v-select
|
||||
multiple chips closable-chips
|
||||
density="compact"
|
||||
item-title="displayName"
|
||||
item-value="type"
|
||||
:disabled="loading"
|
||||
:placeholder="tt('None')"
|
||||
:items="[
|
||||
{ type: TransactionType.Expense, displayName: tt('Expense') },
|
||||
{ type: TransactionType.Income, displayName: tt('Income') },
|
||||
{ type: TransactionType.Transfer, displayName: tt('Transfer') }
|
||||
]"
|
||||
v-model="conditionWithRelation.condition.value"
|
||||
v-if="conditionWithRelation.condition.field === TransactionExploreConditionField.TransactionType.value"
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item :value="item.value" v-bind="props">
|
||||
<template #title>
|
||||
<v-list-item-title>
|
||||
<div class="d-flex align-center">{{ item.title }}</div>
|
||||
</v-list-item-title>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-text-field
|
||||
class="always-cursor-pointer"
|
||||
density="compact"
|
||||
item-title="displayName"
|
||||
item-value="type"
|
||||
persistent-placeholder
|
||||
:readonly="true"
|
||||
:disabled="loading || !hasAnyTransactionCategory"
|
||||
:placeholder="tt('None')"
|
||||
:model-value="getFilteredTransactionCategoriesDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
|
||||
@click="currentCondition = conditionWithRelation.condition; showFilterTransactionCategoriesDialog = true"
|
||||
v-else-if="conditionWithRelation.condition.field === TransactionExploreConditionField.TransactionCategory.value"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
class="always-cursor-pointer"
|
||||
density="compact"
|
||||
item-title="displayName"
|
||||
item-value="type"
|
||||
persistent-placeholder
|
||||
:readonly="true"
|
||||
:disabled="loading || !hasAnyAccount"
|
||||
:placeholder="tt('None')"
|
||||
:model-value="getFilteredAccountsDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
|
||||
@click="currentCondition = conditionWithRelation.condition; showFilterSourceAccountsDialog = true"
|
||||
v-else-if="conditionWithRelation.condition.field === TransactionExploreConditionField.SourceAccount.value"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
class="always-cursor-pointer"
|
||||
density="compact"
|
||||
item-title="displayName"
|
||||
item-value="type"
|
||||
persistent-placeholder
|
||||
:readonly="true"
|
||||
:disabled="loading || !hasAnyAccount"
|
||||
:placeholder="tt('None')"
|
||||
:model-value="getFilteredAccountsDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
|
||||
@click="currentCondition = conditionWithRelation.condition; showFilterDestinationAccountsDialog = true"
|
||||
v-else-if="conditionWithRelation.condition.field === TransactionExploreConditionField.DestinationAccount.value"
|
||||
/>
|
||||
|
||||
<div class="d-flex w-100 align-center gap-2"
|
||||
v-else-if="conditionWithRelation.condition.field === TransactionExploreConditionField.SourceAmount.value ||
|
||||
conditionWithRelation.condition.field === TransactionExploreConditionField.DestinationAmount.value">
|
||||
<amount-input density="compact"
|
||||
:currency="defaultCurrency"
|
||||
:disabled="loading"
|
||||
v-model="conditionWithRelation.condition.value[0]"
|
||||
/>
|
||||
<span class="ms-2 me-2"
|
||||
v-if="conditionWithRelation.condition.operator === TransactionExploreConditionOperator.Between.value ||
|
||||
conditionWithRelation.condition.operator === TransactionExploreConditionOperator.NotBetween.value">~</span>
|
||||
<amount-input density="compact"
|
||||
:currency="defaultCurrency"
|
||||
:disabled="loading"
|
||||
v-model="conditionWithRelation.condition.value[1]"
|
||||
v-if="conditionWithRelation.condition.operator === TransactionExploreConditionOperator.Between.value ||
|
||||
conditionWithRelation.condition.operator === TransactionExploreConditionOperator.NotBetween.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex w-100" v-else-if="conditionWithRelation.condition.field === TransactionExploreConditionField.TransactionTag.value">
|
||||
<v-text-field
|
||||
disabled
|
||||
persistent-placeholder
|
||||
density="compact"
|
||||
:placeholder="tt('None')"
|
||||
v-if="conditionWithRelation.condition.field === TransactionExploreConditionField.TransactionTag.value &&
|
||||
(conditionWithRelation.condition.operator === TransactionExploreConditionOperator.IsEmpty.value || conditionWithRelation.condition.operator === TransactionExploreConditionOperator.IsNotEmpty.value)"
|
||||
/>
|
||||
|
||||
<v-autocomplete
|
||||
density="compact"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
auto-select-first
|
||||
persistent-placeholder
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
:disabled="loading"
|
||||
:placeholder="tt('None')"
|
||||
:items="allTags"
|
||||
v-model="conditionWithRelation.condition.value"
|
||||
v-model:search="tagSearchContent"
|
||||
v-else-if="conditionWithRelation.condition.operator !== TransactionExploreConditionOperator.IsEmpty.value && conditionWithRelation.condition.operator !== TransactionExploreConditionOperator.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"
|
||||
:placeholder="tt('None')"
|
||||
v-else-if="conditionWithRelation.condition.field === TransactionExploreConditionField.Description.value &&
|
||||
conditionWithRelation.condition.operator === TransactionExploreConditionOperator.IsEmpty.value || conditionWithRelation.condition.operator === TransactionExploreConditionOperator.IsNotEmpty.value"
|
||||
/>
|
||||
|
||||
<v-text-field density="compact"
|
||||
:disabled="loading"
|
||||
:placeholder="tt('None')"
|
||||
v-model="conditionWithRelation.condition.value"
|
||||
v-else-if="conditionWithRelation.condition.field === TransactionExploreConditionField.Description.value &&
|
||||
conditionWithRelation.condition.operator !== TransactionExploreConditionOperator.IsEmpty.value && conditionWithRelation.condition.operator !== TransactionExploreConditionOperator.IsNotEmpty.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-btn color="default" density="compact"
|
||||
variant="text" size="small"
|
||||
:icon="true"
|
||||
:disabled="loading"
|
||||
@click="removeCondition(queryIndex, conditionIndex)">
|
||||
<v-icon :icon="mdiClose" size="18" />
|
||||
<v-tooltip activator="parent">{{ tt('Remove Condition') }}</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="showExpression">
|
||||
<div class="w-100 code-container">
|
||||
<v-textarea class="w-100 always-cursor-text mb-4" :readonly="true"
|
||||
:value="getExpression(queryIndex)"></v-textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-btn color="primary" density="comfortable"
|
||||
variant="text" size="small"
|
||||
:prepend-icon="mdiPlus"
|
||||
:disabled="loading || showExpression"
|
||||
@click="addCondition(queryIndex)">
|
||||
{{ tt('Add Condition') }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<div class="query-group-separator d-flex align-center justify-center my-4"
|
||||
v-if="queries.length > 1 && queryIndex < queries.length - 1">
|
||||
<v-chip color="primary" variant="outlined" size="small">
|
||||
{{ tt('or') }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-dialog width="800" v-model="showFilterSourceAccountsDialog">
|
||||
<account-filter-settings-card type="custom" :dialog-mode="true"
|
||||
:selected-account-ids="isArray(currentCondition?.value) ? currentCondition?.value as string[] : []"
|
||||
@settings:change="updateSourceAccount" />
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog width="800" v-model="showFilterDestinationAccountsDialog">
|
||||
<account-filter-settings-card type="custom" :dialog-mode="true"
|
||||
:selected-account-ids="isArray(currentCondition?.value) ? currentCondition?.value as string[] : []"
|
||||
@settings:change="updateDestinationAccount" />
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog width="800" v-model="showFilterTransactionCategoriesDialog">
|
||||
<category-filter-settings-card type="custom" :dialog-mode="true"
|
||||
:selected-category-ids="isArray(currentCondition?.value) ? currentCondition?.value as string[] : []"
|
||||
@settings:change="updateTransactionCategories" />
|
||||
</v-dialog>
|
||||
|
||||
<snack-bar ref="snackbar" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AccountFilterSettingsCard from '@/views/desktop/common/cards/AccountFilterSettingsCard.vue';
|
||||
import CategoryFilterSettingsCard from '@/views/desktop/common/cards/CategoryFilterSettingsCard.vue';
|
||||
|
||||
import SnackBar from '@/components/desktop/SnackBar.vue';
|
||||
|
||||
import { ref, computed, useTemplateRef } from 'vue';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
import { useAccountsStore } from '@/stores/account.ts';
|
||||
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts';
|
||||
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
|
||||
import { useExploresStore } from '@/stores/explore.ts';
|
||||
|
||||
import { type NameValue, values } from '@/core/base.ts';
|
||||
import { AccountType } from '@/core/account.ts';
|
||||
import { TransactionType } from '@/core/transaction.ts';
|
||||
import {
|
||||
TransactionExploreConditionRelation,
|
||||
TransactionExploreConditionField,
|
||||
TransactionExploreConditionOperator
|
||||
} from '@/core/explore.ts';
|
||||
|
||||
import {
|
||||
type TransactionTag
|
||||
} from '@/models/transaction_tag.ts';
|
||||
|
||||
import {
|
||||
type TransactionExploreCondition,
|
||||
TransactionExploreQuery
|
||||
} from '@/models/explore.ts';
|
||||
|
||||
import {
|
||||
isArray,
|
||||
isObjectEmpty,
|
||||
arrayItemToObjectField
|
||||
} from '@/lib/common.ts';
|
||||
|
||||
import logger from '@/lib/logger.ts';
|
||||
|
||||
import {
|
||||
mdiPlus,
|
||||
mdiClose,
|
||||
mdiPound
|
||||
} from '@mdi/js';
|
||||
|
||||
interface ExploreQueryTabProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
type SnackBarType = InstanceType<typeof SnackBar>;
|
||||
|
||||
const props = defineProps<ExploreQueryTabProps>();
|
||||
|
||||
const {
|
||||
tt,
|
||||
joinMultiText,
|
||||
getAllTransactionExploreConditionFields,
|
||||
getAllTransactionExploreConditionOperators
|
||||
} = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const accountsStore = useAccountsStore();
|
||||
const transactionCategoriesStore = useTransactionCategoriesStore();
|
||||
const transactionTagsStore = useTransactionTagsStore();
|
||||
const exploresStore = useExploresStore();
|
||||
|
||||
const snackbar = useTemplateRef<SnackBarType>('snackbar');
|
||||
|
||||
const currentCondition = ref<TransactionExploreCondition | undefined>(undefined);
|
||||
const showExpression = ref<boolean>(false);
|
||||
const showFilterSourceAccountsDialog = ref<boolean>(false);
|
||||
const showFilterDestinationAccountsDialog = ref<boolean>(false);
|
||||
const showFilterTransactionCategoriesDialog = ref<boolean>(false);
|
||||
const tagSearchContent = ref<string>('');
|
||||
|
||||
const queries = computed<TransactionExploreQuery[]>(() => exploresStore.transactionExploreFilter.query);
|
||||
|
||||
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 allTransactionExploreConditionFields = computed<NameValue[]>(() => getAllTransactionExploreConditionFields());
|
||||
|
||||
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 '';
|
||||
}
|
||||
|
||||
if (!filterAccountIds) {
|
||||
return tt('All');
|
||||
}
|
||||
|
||||
let allAccountSelected = true;
|
||||
const selectedAccountNames: string[] = [];
|
||||
|
||||
for (const account of accountsStore.allPlainAccounts) {
|
||||
if (account.type === AccountType.MultiSubAccounts.type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!filterAccountIds[account.id]) {
|
||||
allAccountSelected = false;
|
||||
} else {
|
||||
selectedAccountNames.push(account.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (allAccountSelected) {
|
||||
return tt('All');
|
||||
} else if (selectedAccountNames.length < 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return joinMultiText(selectedAccountNames);
|
||||
}
|
||||
|
||||
function getFilteredTransactionCategoriesDisplayContent(filterTransactionCategoryIds?: Record<string, boolean>): string {
|
||||
if ((props.loading && !hasAnyTransactionCategory.value) || !transactionCategoriesStore.allTransactionCategoriesMap) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!filterTransactionCategoryIds) {
|
||||
return tt('All');
|
||||
}
|
||||
|
||||
let allCategorySelected = true;
|
||||
const selectedCategoryNames: string[] = [];
|
||||
|
||||
for (const transactionCategory of values(transactionCategoriesStore.allTransactionCategoriesMap)) {
|
||||
if (!transactionCategory.parentId || transactionCategory.parentId === '0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!filterTransactionCategoryIds[transactionCategory.id]) {
|
||||
allCategorySelected = false;
|
||||
} else {
|
||||
selectedCategoryNames.push(transactionCategory.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (allCategorySelected) {
|
||||
return tt('All');
|
||||
} else if (selectedCategoryNames.length < 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return joinMultiText(selectedCategoryNames);
|
||||
}
|
||||
|
||||
function addQuery(): void {
|
||||
queries.value.push(TransactionExploreQuery.create());
|
||||
}
|
||||
|
||||
function removeQuery(queryIndex: number): void {
|
||||
if (queries.value.length > 0) {
|
||||
queries.value.splice(queryIndex, 1);
|
||||
}
|
||||
|
||||
if (queries.value.length < 1) {
|
||||
queries.value.push(TransactionExploreQuery.create());
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllQueries(): void {
|
||||
queries.value.length = 0;
|
||||
queries.value.push(TransactionExploreQuery.create());
|
||||
}
|
||||
|
||||
function addCondition(queryIndex: number): void {
|
||||
const query = queries.value[queryIndex];
|
||||
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newCondition = query.addNewCondition(TransactionExploreConditionField.TransactionType, query.conditions.length < 1);
|
||||
query.conditions.push(newCondition);
|
||||
}
|
||||
|
||||
function removeCondition(queryIndex: number, conditionIndex: number): void {
|
||||
const query = queries.value[queryIndex];
|
||||
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
query.conditions.splice(conditionIndex, 1);
|
||||
|
||||
if (conditionIndex === 0 && query.conditions.length > 0) {
|
||||
const newFirstCondition = query.conditions[0];
|
||||
|
||||
if (newFirstCondition) {
|
||||
newFirstCondition.relation = TransactionExploreConditionRelation.First;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateConditionField(queryIndex: number, conditionIndex: number, newField: TransactionExploreConditionField | undefined): void {
|
||||
if (!newField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = queries.value[queryIndex];
|
||||
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldConditionWithRelation = query.conditions[conditionIndex];
|
||||
|
||||
if (!oldConditionWithRelation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newConditionWithRelation = query.addNewCondition(newField, conditionIndex < 1);
|
||||
oldConditionWithRelation.condition = newConditionWithRelation.condition;
|
||||
}
|
||||
|
||||
function updateSourceAccount(changed: boolean, selectedAccountIds?: string[]): void {
|
||||
if (!changed || !currentCondition.value || currentCondition.value.field !== TransactionExploreConditionField.SourceAccount.value) {
|
||||
showFilterSourceAccountsDialog.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
currentCondition.value.value = selectedAccountIds || [];
|
||||
currentCondition.value = undefined;
|
||||
showFilterSourceAccountsDialog.value = false;
|
||||
}
|
||||
|
||||
function updateDestinationAccount(changed: boolean, selectedAccountIds?: string[]): void {
|
||||
if (!changed || !currentCondition.value || currentCondition.value.field !== TransactionExploreConditionField.DestinationAccount.value) {
|
||||
showFilterDestinationAccountsDialog.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
currentCondition.value.value = selectedAccountIds || [];
|
||||
currentCondition.value = undefined;
|
||||
showFilterDestinationAccountsDialog.value = false;
|
||||
}
|
||||
|
||||
function updateTransactionCategories(changed: boolean, selectedCategoryIds?: string[]): void {
|
||||
if (!changed || !currentCondition.value || currentCondition.value.field !== TransactionExploreConditionField.TransactionCategory.value) {
|
||||
showFilterTransactionCategoriesDialog.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
currentCondition.value.value = selectedCategoryIds || [];
|
||||
currentCondition.value = undefined;
|
||||
showFilterTransactionCategoriesDialog.value = false;
|
||||
}
|
||||
|
||||
function getExpression(queryIndex: number): string {
|
||||
const query = queries.value[queryIndex];
|
||||
|
||||
if (!query) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return query.toExpression(transactionCategoriesStore.allTransactionCategoriesMap, accountsStore.allAccountsMap, transactionTagsStore.allTransactionTagsMap);
|
||||
} catch (ex) {
|
||||
logger.error('failed to generate expression for explore query#' + queryIndex, ex);
|
||||
snackbar.value?.showError(tt('Failed to generate expression'));
|
||||
return tt('Failed to generate expression');
|
||||
}
|
||||
}
|
||||
|
||||
if (queries.value.length === 0) {
|
||||
queries.value.push(TransactionExploreQuery.create());
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user