save insights explorer to database

This commit is contained in:
MaysWind
2026-01-07 01:04:54 +08:00
parent d4d1342c70
commit d462d0164c
36 changed files with 2091 additions and 286 deletions
+189 -73
View File
@@ -5,24 +5,22 @@
<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" />
<btn-vertical-group :disabled="loading || updating" :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="currentExplorationId">
<v-tab class="tab-text-truncate" key="new" value="">
<span class="text-truncate">{{ tt('New Exploration') }}</span>
:disabled="loading || updating" :model-value="currentExplorer.id">
<v-tab class="tab-text-truncate" key="new" value="" @click="createNewExplorer">
<span class="text-truncate">{{ tt('New Explorer') }}</span>
</v-tab>
<v-tab class="tab-text-truncate" :key="explorer.id" :value="explorer.id"
v-for="explorer in allExplorers"
@click="loadExplorer(explorer.id)">
<span class="text-truncate">{{ explorer.name || tt('Untitled Explorer') }}</span>
</v-tab>
<!-- <v-btn class="text-left justify-start" variant="text" color="default" :rounded="false">-->
<!-- <span class="ps-2">{{ tt('More Explorer') }}</span>-->
<!-- </v-btn>-->
</v-tabs>
</v-navigation-drawer>
<v-main>
@@ -36,23 +34,23 @@
<span>{{ tt('Insights Explorer') }}</span>
<v-btn-group class="ms-4" color="default" density="comfortable" variant="outlined" divided>
<v-btn class="button-icon-with-direction" :icon="mdiArrowLeft"
:disabled="loading || !canShiftDateRange"
:disabled="loading || updating || !canShiftDateRange"
@click="shiftDateRange(-1)"/>
<v-menu location="bottom" max-height="500">
<template #activator="{ props }">
<v-btn :disabled="loading"
<v-btn :disabled="loading || updating"
v-bind="props">{{ displayQueryDateRangeName }}</v-btn>
</template>
<v-list :selected="[query.dateRangeType]">
<v-list :selected="[currentFilter.dateRangeType]">
<v-list-item :key="dateRange.type" :value="dateRange.type"
:append-icon="(query.dateRangeType === dateRange.type ? mdiCheck : undefined)"
:append-icon="(currentFilter.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">
<div class="statistics-custom-datetime-range smaller" v-if="dateRange.isUserCustomRange && currentFilter.dateRangeType === dateRange.type && !!currentFilter.startTime && !!currentFilter.endTime">
<span>{{ displayQueryStartTime }}</span>
<span>&nbsp;-&nbsp;</span>
<br/>
@@ -63,12 +61,12 @@
</v-list>
</v-menu>
<v-btn class="button-icon-with-direction" :icon="mdiArrowRight"
:disabled="loading || !canShiftDateRange"
:disabled="loading || updating || !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)">
class="ms-2" :icon="true" :loading="loading" :disabled="updating" @click="reload(true)">
<template #loader>
<v-progress-circular indeterminate size="20"/>
</template>
@@ -76,8 +74,28 @@
<v-tooltip activator="parent">{{ tt('Refresh') }}</v-tooltip>
</v-btn>
<v-spacer/>
<v-btn class="ms-3" color="default" variant="outlined"
:disabled="loading || updating" @click="saveExplorer(false)">
{{ tt('Save Explorer') }}
<v-progress-circular indeterminate size="22" class="ms-2" v-if="updating"></v-progress-circular>
<v-menu activator="parent" :open-on-hover="true">
<v-list>
<v-list-item :prepend-icon="mdiContentSaveOutline" @click="saveExplorer(true)">
<v-list-item-title>{{ tt('Save As New Explorer') }}</v-list-item-title>
</v-list-item>
<v-divider class="my-2" v-if="currentExplorer.id" />
<v-list-item :prepend-icon="mdiPencilOutline" @click="setExplorerName" v-if="currentExplorer.id">
<v-list-item-title>{{ tt('Rename Explorer') }}</v-list-item-title>
</v-list-item>
<v-divider class="my-2" v-if="currentExplorer.id" />
<v-list-item :prepend-icon="mdiDeleteOutline" @click="removeExplorer" v-if="currentExplorer.id">
<v-list-item-title>{{ tt('Delete Explorer') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn density="comfortable" color="default" variant="text" class="ms-2"
:disabled="loading" :icon="true">
:disabled="loading || updating" :icon="true">
<v-icon :icon="mdiDotsVertical" />
<v-menu activator="parent">
<v-list>
@@ -86,14 +104,14 @@
<template v-if="activeTab === 'query'">
<v-list-item :key="timezoneType.type" :value="timezoneType.type"
:prepend-icon="timezoneTypeIconMap[timezoneType.type]"
:append-icon="(query.timezoneUsedForDateRange === timezoneType.type ? mdiCheck : undefined)"
:append-icon="(currentExplorer.timezoneUsedForDateRange === timezoneType.type ? mdiCheck : undefined)"
:title="timezoneType.displayName"
v-for="timezoneType in allTimezoneTypesUsedForDateRange"
@click="updateTimezoneUsedForDateRange(timezoneType.type)"></v-list-item>
@click="currentExplorer.timezoneUsedForDateRange = timezoneType.type"></v-list-item>
</template>
<v-list-item :prepend-icon="mdiExport"
:title="tt('Export Results')"
:disabled="loading || !filteredTransactions || filteredTransactions.length < 1"
:disabled="loading || updating || !filteredTransactions || filteredTransactions.length < 1"
@click="exportResults"
v-if="activeTab === 'table' || activeTab === 'chart'"></v-list-item>
</v-list>
@@ -104,17 +122,16 @@
<v-window class="d-flex flex-grow-1 disable-tab-transition w-100-window-container" v-model="activeTab">
<v-window-item value="query">
<explorer-query-tab :loading="loading" />
<explorer-query-tab :loading="loading" :disabled="loading || updating" />
</v-window-item>
<v-window-item value="table">
<explorer-data-table-tab ref="explorerDataTableTab"
:loading="loading"
v-model:count-per-page="countPerPage"
:loading="loading" :disabled="loading || updating"
@click:transaction="onShowTransaction" />
</v-window-item>
<v-window-item value="chart">
<explorer-chart-tab ref="explorerChartTab"
:loading="loading" />
:loading="loading" :disabled="loading || updating" />
</v-window-item>
</v-window>
</v-card>
@@ -125,25 +142,29 @@
</v-row>
<date-range-selection-dialog :title="tt('Custom Date Range')"
:min-time="query.startTime"
:max-time="query.endTime"
:min-time="currentFilter.startTime"
:max-time="currentFilter.endTime"
v-model:show="showCustomDateRangeDialog"
@dateRange:change="setCustomDateFilter"
@error="onShowDateRangeError" />
<explorer-rename-dialog ref="explorerRenameDialog" />
<edit-dialog ref="editDialog" :type="TransactionEditPageType.Transaction" />
<export-dialog ref="exportDialog" />
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
</template>
<script setup lang="ts">
import ConfirmDialog from '@/components/desktop/ConfirmDialog.vue';
import SnackBar from '@/components/desktop/SnackBar.vue';
import ExplorerQueryTab from '@/views/desktop/insights/tabs/ExplorerQueryTab.vue';
import ExplorerDataTableTab from '@/views/desktop/insights/tabs/ExplorerDataTableTab.vue';
import ExplorerChartTab from '@/views/desktop/insights/tabs/ExplorerChartTab.vue';
import ExplorerRenameDialog from '@/views/desktop/insights/dialogs/ExplorerRenameDialog.vue';
import EditDialog from '@/views/desktop/transactions/list/dialogs/EditDialog.vue';
import ExportDialog from '@/views/desktop/statistics/transaction/dialogs/ExportDialog.vue';
import SnackBar from '@/components/desktop/SnackBar.vue';
import { ref, computed, useTemplateRef, watch } from 'vue';
import { useRouter, onBeforeRouteUpdate } from 'vue-router';
@@ -158,15 +179,12 @@ import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts';
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { type TransactionExplorerPartialFilter, type TransactionExplorerFilter, useExplorersStore } from '@/stores/explorer.ts';
import type { NameNumeralValue, TypeAndDisplayName } from '@/core/base.ts';
import type { NumeralSystem } from '@/core/numeral.ts';
import type { TypeAndDisplayName } from '@/core/base.ts';
import { type WeekDayValue, type LocalizedDateRange, DateRangeScene, DateRange } from '@/core/datetime.ts';
import { TimezoneTypeForStatistics } from '@/core/timezone.ts';
import {
type TransactionInsightDataItem,
Transaction
} from '@/models/transaction.ts';
import { type TransactionInsightDataItem, Transaction } from '@/models/transaction.ts';
import { type InsightsExplorerBasicInfo, InsightsExplorer } from '@/models/explorer.ts';
import {
parseDateTimeFromUnixTime,
@@ -175,6 +193,8 @@ import {
getDateRangeByDateType
} from '@/lib/datetime.ts';
import { generateRandomUUID } from '@/lib/misc.ts';
import {
mdiMenu,
mdiArrowLeft,
@@ -182,6 +202,9 @@ import {
mdiCheck,
mdiRefresh,
mdiDotsVertical,
mdiContentSaveOutline,
mdiPencilOutline,
mdiDeleteOutline,
mdiHomeClockOutline,
mdiInvoiceTextClockOutline,
mdiExport
@@ -198,9 +221,12 @@ interface InsightsExplorerProps {
const props = defineProps<InsightsExplorerProps>();
type ExplorerPageTabType = 'query' | 'table' | 'chart';
type ConfirmDialogType = InstanceType<typeof ConfirmDialog>;
type SnackBarType = InstanceType<typeof SnackBar>;
type ExplorerDataTableTabType = InstanceType<typeof ExplorerDataTableTab>;
type ExplorerChartTabType = InstanceType<typeof ExplorerChartTab>;
type ExplorerRenameDialogType = InstanceType<typeof ExplorerRenameDialog>;
type EditDialogType = InstanceType<typeof EditDialog>;
type ExportDialogType = InstanceType<typeof ExportDialog>;
@@ -211,7 +237,6 @@ const {
tt,
getAllDateRanges,
getAllTimezoneTypesUsedForStatistics,
getCurrentNumeralSystemType,
formatDateTimeToLongDateTime,
formatDateRange
} = useI18n();
@@ -227,34 +252,37 @@ const timezoneTypeIconMap = {
[TimezoneTypeForStatistics.TransactionTimezone.type]: mdiInvoiceTextClockOutline
};
const confirmDialog = useTemplateRef<ConfirmDialogType>('confirmDialog');
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const explorerDataTableTab = useTemplateRef<ExplorerDataTableTabType>('explorerDataTableTab');
const explorerChartTab = useTemplateRef<ExplorerChartTabType>('explorerChartTab');
const explorerRenameDialog = useTemplateRef<ExplorerRenameDialogType>('explorerRenameDialog');
const exportDialog = useTemplateRef<ExportDialogType>('exportDialog');
const editDialog = useTemplateRef<EditDialogType>('editDialog');
const loading = ref<boolean>(true);
const initing = ref<boolean>(true);
const updating = ref<boolean>(false);
const clientSessionId = ref<string>('');
const alwaysShowNav = ref<boolean>(display.mdAndUp.value);
const showNav = ref<boolean>(display.mdAndUp.value);
const activeTab = ref<ExplorerPageTabType>('query');
const currentExplorationId = 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<TransactionExplorerFilter>(() => explorersStore.transactionExplorerFilter);
const allExplorers = computed<InsightsExplorerBasicInfo[]>(() => explorersStore.allInsightsExplorerBasicInfos.slice(0, 15));
const currentFilter = computed<TransactionExplorerFilter>(() => explorersStore.transactionExplorerFilter);
const currentExplorer = computed<InsightsExplorer>(() => explorersStore.currentInsightsExplorer);
const filteredTransactions = computed<TransactionInsightDataItem[]>(() => explorersStore.filteredTransactions);
const allDateRanges = computed<LocalizedDateRange[]>(() => getAllDateRanges(DateRangeScene.InsightsExplorer, true));
const allTimezoneTypesUsedForDateRange = computed<TypeAndDisplayName[]>(() => getAllTimezoneTypesUsedForStatistics());
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>(() => formatDateTimeToLongDateTime(parseDateTimeFromUnixTime(query.value.startTime)));
const displayQueryEndTime = computed<string>(() => formatDateTimeToLongDateTime(parseDateTimeFromUnixTime(query.value.endTime)));
const canShiftDateRange = computed<boolean>(() => currentFilter.value.dateRangeType !== DateRange.All.type);
const displayQueryDateRangeName = computed<string>(() => formatDateRange(currentFilter.value.dateRangeType, currentFilter.value.startTime, currentFilter.value.endTime));
const displayQueryStartTime = computed<string>(() => formatDateTimeToLongDateTime(parseDateTimeFromUnixTime(currentFilter.value.startTime)));
const displayQueryEndTime = computed<string>(() => formatDateTimeToLongDateTime(parseDateTimeFromUnixTime(currentFilter.value.endTime)));
const allTabs = computed<{ name: string, value: ExplorerPageTabType }[]>(() => {
return [
@@ -273,24 +301,13 @@ const allTabs = computed<{ name: string, value: ExplorerPageTabType }[]>(() => {
];
});
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/explorer?${explorersStore.getTransactionExplorerPageParams(currentExplorationId.value, activeTab.value)}`;
return `/insights/explorer?${explorersStore.getTransactionExplorerPageParams(currentExplorer.value.id, activeTab.value)}`;
}
function init(initProps: InsightsExplorerProps): void {
clientSessionId.value = generateRandomUUID();
const filter: TransactionExplorerPartialFilter = {
dateRangeType: initProps.initDateRangeType ? parseInt(initProps.initDateRangeType) : undefined,
startTime: initProps.initStartTime ? parseInt(initProps.initStartTime) : undefined,
@@ -299,11 +316,11 @@ function init(initProps: InsightsExplorerProps): void {
let needReload = false;
if (filter.dateRangeType !== query.value.dateRangeType) {
if (filter.dateRangeType !== currentFilter.value.dateRangeType) {
needReload = true;
} else if (filter.dateRangeType === DateRange.Custom.type) {
if (filter.startTime !== query.value.startTime
|| filter.endTime !== query.value.endTime) {
if (filter.startTime !== currentFilter.value.startTime
|| filter.endTime !== currentFilter.value.endTime) {
needReload = true;
}
}
@@ -311,7 +328,6 @@ function init(initProps: InsightsExplorerProps): void {
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';
@@ -319,7 +335,15 @@ function init(initProps: InsightsExplorerProps): void {
explorersStore.initTransactionExplorerFilter(filter);
if (!needReload && !explorersStore.transactionExplorerStateInvalid) {
if (initProps.initId) {
if (explorersStore.currentInsightsExplorer.id !== initProps.initId) {
loadExplorer(initProps.initId);
}
} else {
explorersStore.updateCurrentInsightsExplorer(InsightsExplorer.createNewExplorer());
}
if (!needReload && !explorersStore.transactionExplorerStateInvalid && !explorersStore.insightsExplorerListStateInvalid) {
loading.value = false;
initing.value = false;
return;
@@ -328,7 +352,8 @@ function init(initProps: InsightsExplorerProps): void {
Promise.all([
accountsStore.loadAllAccounts({ force: false }),
transactionCategoriesStore.loadAllCategories({ force: false }),
transactionTagsStore.loadAllTags({ force: false })
transactionTagsStore.loadAllTags({ force: false }),
explorersStore.loadAllInsightsExplorerBasicInfos({ force: false })
]).then(() => {
return explorersStore.loadAllTransactions({ force: false });
}).then(() => {
@@ -364,9 +389,100 @@ function reload(force: boolean): Promise<unknown> | null {
});
}
function updateTimezoneUsedForDateRange(timezoneType: number): void {
explorersStore.updateTransactionExplorerFilter({
timezoneUsedForDateRange: timezoneType
function createNewExplorer(): void {
if (!currentExplorer.value.id) {
return;
}
explorersStore.updateCurrentInsightsExplorer(InsightsExplorer.createNewExplorer());
router.push(getFilterLinkUrl());
}
function loadExplorer(explorerId: string): void {
if (currentExplorer.value.id === explorerId) {
return;
}
loading.value = true;
explorersStore.getInsightsExplorer({
explorerId: explorerId
}).then(explorer => {
explorersStore.updateCurrentInsightsExplorer(explorer);
loading.value = false;
router.push(getFilterLinkUrl());
}).catch(error => {
loading.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function saveExplorer(saveAs?: boolean): void {
if (saveAs || !currentExplorer.value.name) {
explorerRenameDialog.value?.open(currentExplorer.value.name || '').then((newName: string) => {
currentExplorer.value.name = newName;
doSaveExplorer(saveAs);
})
} else {
doSaveExplorer(saveAs);
}
}
function doSaveExplorer(saveAs?: boolean): Promise<unknown> {
const oldExplorerId = currentExplorer.value.id;
updating.value = true;
return explorersStore.saveInsightsExplorer({
explorer: currentExplorer.value,
saveAs: saveAs,
clientSessionId: clientSessionId.value
}).then(newExplorer => {
updating.value = false;
clientSessionId.value = generateRandomUUID();
explorersStore.updateCurrentInsightsExplorer(newExplorer);
if (oldExplorerId !== newExplorer.id) {
router.push(getFilterLinkUrl());
}
}).catch(error => {
updating.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function setExplorerName(): void {
explorerRenameDialog.value?.open(currentExplorer.value.name || '').then((newName: string) => {
currentExplorer.value.name = newName;
});
}
function removeExplorer(): void {
if (!currentExplorer.value.id) {
return;
}
confirmDialog.value?.open('Are you sure you want to delete this explorer?').then(() => {
updating.value = true;
explorersStore.deleteInsightsExplorer({
explorer: currentExplorer.value
}).then(() => {
updating.value = false;
createNewExplorer();
}).catch(error => {
updating.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
});
}
@@ -390,7 +506,7 @@ function setDateFilter(dateType: number): void {
if (dateType === DateRange.Custom.type) { // Custom
showCustomDateRangeDialog.value = true;
return;
} else if (query.value.dateRangeType === dateType) {
} else if (currentFilter.value.dateRangeType === dateType) {
return;
}
@@ -436,11 +552,11 @@ function setCustomDateFilter(startTime: number, endTime: number): void {
}
function shiftDateRange(scale: number): void {
if (query.value.dateRangeType === DateRange.All.type) {
if (currentFilter.value.dateRangeType === DateRange.All.type) {
return;
}
const newDateRange = getShiftedDateRangeAndDateType(query.value.startTime, query.value.endTime, scale, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal);
const newDateRange = getShiftedDateRangeAndDateType(currentFilter.value.startTime, currentFilter.value.endTime, scale, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal);
const changed = explorersStore.updateTransactionExplorerFilter({
dateRangeType: newDateRange.dateType,
@@ -0,0 +1,66 @@
<template>
<v-dialog max-width="500" :persistent="oldExplorerName !== newExplorerName" v-model="showState">
<v-card class="pa-sm-1 pa-md-2">
<template #title>
<h4 class="text-h4 text-wrap">{{ tt('Rename Explorer') }}</h4>
</template>
<v-card-text class="w-100 d-flex justify-center">
<v-text-field persistent-placeholder
:label="tt('Explorer Name')"
:placeholder="tt('Explorer Name')"
v-model="newExplorerName"/>
</v-card-text>
<v-card-text>
<div class="w-100 d-flex justify-center flex-wrap mt-sm-1 mt-md-2 gap-4">
<v-btn color="primary" :disabled="!newExplorerName || oldExplorerName === newExplorerName" @click="save">
{{ tt('Save') }}
</v-btn>
<v-btn color="secondary" variant="tonal" @click="cancel">
{{ tt('Cancel') }}
</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
const { tt } = useI18n();
let resolveFunc: ((name: string) => void) | null = null;
let rejectFunc: ((reason?: unknown) => void) | null = null;
const showState = ref<boolean>(false);
const oldExplorerName = ref<string>('');
const newExplorerName = ref<string>('');
function open(currentExplorerName: string): Promise<string> {
showState.value = true;
oldExplorerName.value = currentExplorerName;
newExplorerName.value = currentExplorerName;
return new Promise((resolve, reject) => {
resolveFunc = resolve;
rejectFunc = reject;
});
}
function save(): void {
resolveFunc?.(newExplorerName.value);
showState.value = false;
}
function cancel(): void {
rejectFunc?.();
showState.value = false;
}
defineExpose({
open
});
</script>
@@ -9,11 +9,11 @@
item-title="name"
item-value="value"
density="compact"
:disabled="loading"
:disabled="loading || disabled"
:label="tt('Chart Type')"
:items="allTransactionExplorerChartTypes"
:model-value="currentChartType"
@update:model-value="updateChartType($event as TransactionExplorerChartTypeValue)"
:model-value="currentExplorer.chartType"
@update:model-value="currentExplorer.chartType = $event as TransactionExplorerChartTypeValue"
/>
<v-select
class="flex-0-0"
@@ -21,11 +21,11 @@
item-title="name"
item-value="value"
density="compact"
:disabled="loading"
:disabled="loading || disabled"
:label="tt('Axis / Category')"
:items="allTransactionExplorerDataDimensions"
:model-value="currentCategoryDimension"
@update:model-value="updateCategoryDimension($event as TransactionExplorerDataDimensionType)"
:model-value="currentExplorer.categoryDimension"
@update:model-value="currentExplorer.categoryDimension = $event as TransactionExplorerDataDimensionType"
/>
<v-select
class="flex-0-0"
@@ -33,14 +33,14 @@
item-title="name"
item-value="value"
density="compact"
:disabled="loading || !TransactionExplorerChartType.valueOf(currentChartType)?.seriesDimensionRequired"
:disabled="loading || disabled || !TransactionExplorerChartType.valueOf(currentExplorer.chartType)?.seriesDimensionRequired"
:label="tt('Series')"
:items="allTransactionExplorerDataDimensions"
:model-value="TransactionExplorerChartType.valueOf(currentChartType)?.seriesDimensionRequired ? currentSeriesDimension : TransactionExplorerDataDimension.None.value"
@update:model-value="updateSeriesDimension($event as TransactionExplorerDataDimensionType)"
:model-value="TransactionExplorerChartType.valueOf(currentExplorer.chartType)?.seriesDimensionRequired ? currentExplorer.seriesDimension : TransactionExplorerDataDimension.None.value"
@update:model-value="currentExplorer.seriesDimension = $event as TransactionExplorerDataDimensionType"
>
<template #item="{ props, item }">
<v-list-item :disabled="item.value === currentCategoryDimension && item.value !== TransactionExplorerDataDimension.SeriesDimensionDefault.value" v-bind="props">
<v-list-item :disabled="item.value === currentExplorer.categoryDimension && item.value !== TransactionExplorerDataDimension.SeriesDimensionDefault.value" v-bind="props">
<template #title>
<div class="text-truncate">{{ item.raw.name }}</div>
</template>
@@ -53,11 +53,11 @@
item-title="name"
item-value="value"
density="compact"
:disabled="loading"
:disabled="loading || disabled"
:label="tt('Value Metric')"
:items="allTransactionExplorerValueMetrics"
:model-value="currentValueMetric"
@update:model-value="updateValueMetric($event as TransactionExplorerValueMetricType)"
:model-value="currentExplorer.valueMetric"
@update:model-value="currentExplorer.valueMetric = $event as TransactionExplorerValueMetricType"
/>
<v-select
class="flex-0-0"
@@ -65,18 +65,18 @@
item-title="displayName"
item-value="type"
density="compact"
:disabled="loading"
:disabled="loading || disabled"
:label="tt('Sort Order')"
:items="allTransactionExplorerChartSortingTypes"
:model-value="currentChartSortingType"
@update:model-value="updateChartSortingType($event)"
:model-value="currentExplorer.chartSortingType"
@update:model-value="currentExplorer.chartSortingType = $event"
/>
<v-spacer class="flex-1-1"/>
</div>
</v-col>
</v-row>
</v-card-text>
<v-card-text :class="{ 'readonly': loading }" v-if="currentChartType === TransactionExplorerChartType.Pie.value">
<v-card-text :class="{ 'readonly': loading }" v-if="currentExplorer.chartType === TransactionExplorerChartType.Pie.value">
<pie-chart
:items="[
{id: '1', name: '---', value: 60, color: '7c7c7f'},
@@ -95,7 +95,7 @@
:show-value="true"
:show-percent="true"
:enable-click-item="true"
:amount-value="explorersStore.transactionExplorerFilter.valueMetric !== TransactionExplorerValueMetric.TransactionCount.value"
:amount-value="currentExplorer.valueMetric !== TransactionExplorerValueMetric.TransactionCount.value"
:default-currency="defaultCurrency"
id-field="id"
name-field="name"
@@ -104,7 +104,7 @@
@click="onClickPieChartItem"
/>
</v-card-text>
<v-card-text :class="{ 'readonly': loading }" v-if="currentChartType === TransactionExplorerChartType.Radar.value">
<v-card-text :class="{ 'readonly': loading }" v-if="currentExplorer.chartType === TransactionExplorerChartType.Radar.value">
<radar-chart
:items="[
{name: '---', value: 10},
@@ -124,7 +124,7 @@
:min-valid-percent="0.0001"
:show-value="true"
:show-percent="true"
:amount-value="explorersStore.transactionExplorerFilter.valueMetric !== TransactionExplorerValueMetric.TransactionCount.value"
:amount-value="currentExplorer.valueMetric !== TransactionExplorerValueMetric.TransactionCount.value"
:default-currency="defaultCurrency"
name-field="name"
value-field="totalAmount"
@@ -159,6 +159,7 @@ import {
} from '@/core/explorer.ts';
import { type SortableTransactionStatisticDataItem } from '@/models/transaction.ts';
import type { InsightsExplorer } from '@/models/explorer.ts';
import { isDefined } from '@/lib/common.ts';
import { parseDateTimeFromString } from '@/lib/datetime.ts';
@@ -166,6 +167,7 @@ import { sortStatisticsItems } from '@/lib/statistics.ts';
interface InsightsExplorerDataTableTabProps {
loading?: boolean;
disabled?: boolean;
}
interface CategoryDimensionData extends SortableTransactionStatisticDataItem {
@@ -210,14 +212,10 @@ const allTransactionExplorerValueMetrics = computed<NameValue[]>(() => getAllTra
const allTransactionExplorerChartTypes = computed<NameValue[]>(() => getAllTransactionExplorerChartTypes());
const allTransactionExplorerChartSortingTypes = computed<TypeAndDisplayName[]>(() => getAllStatisticsSortingTypes());
const currentCategoryDimension = computed<TransactionExplorerDataDimensionType>(() => explorersStore.transactionExplorerFilter.categoryDimension);
const currentSeriesDimension = computed<TransactionExplorerDataDimensionType>(() => explorersStore.transactionExplorerFilter.seriesDimension);
const currentValueMetric = computed<TransactionExplorerValueMetricType>(() => explorersStore.transactionExplorerFilter.valueMetric);
const currentChartType = computed<TransactionExplorerChartTypeValue>(() => explorersStore.transactionExplorerFilter.chartType);
const currentChartSortingType = computed<number>(() => explorersStore.transactionExplorerFilter.chartSortingType);
const currentExplorer = computed<InsightsExplorer>(() => explorersStore.currentInsightsExplorer);
const categoryDimensionTransactionExplorerData = computed<CategoryDimensionData[]>(() => {
if (currentChartType.value !== TransactionExplorerChartType.Pie.value && currentChartType.value !== TransactionExplorerChartType.Radar.value) {
if (currentExplorer.value.chartType !== TransactionExplorerChartType.Pie.value && currentExplorer.value.chartType !== TransactionExplorerChartType.Radar.value) {
return [];
}
@@ -245,7 +243,7 @@ const categoryDimensionTransactionExplorerData = computed<CategoryDimensionData[
});
}
sortStatisticsItems(result, explorersStore.transactionExplorerFilter.chartSortingType);
sortStatisticsItems(result, currentExplorer.value.chartSortingType);
return result;
});
@@ -262,13 +260,13 @@ function getCategoriedDataDisplayName(info: CategoriedInfo | SeriesedInfo): stri
needI18n = info.categoryNameNeedI18n;
i18nParameters = info.categoryNameI18nParameters;
dimessionType = info.categoryIdType;
dimession = explorersStore.transactionExplorerFilter.categoryDimension;
dimession = currentExplorer.value.categoryDimension;
} else if ('seriesName' in info) {
name = info.seriesName;
needI18n = info.seriesNameNeedI18n;
i18nParameters = info.seriesNameI18nParameters;
dimessionType = info.seriesIdType;
dimession = explorersStore.transactionExplorerFilter.seriesDimension;
dimession = currentExplorer.value.seriesDimension;
}
let displayName: string = name;
@@ -323,36 +321,6 @@ function getCategoriedDataDisplayName(info: CategoriedInfo | SeriesedInfo): stri
return displayName;
}
function updateChartType(chartType: TransactionExplorerChartTypeValue): void {
explorersStore.updateTransactionExplorerFilter({
chartType: chartType,
});
}
function updateCategoryDimension(categoryDimension: TransactionExplorerDataDimensionType): void {
explorersStore.updateTransactionExplorerFilter({
categoryDimension: categoryDimension,
});
}
function updateSeriesDimension(seriesDimension: TransactionExplorerDataDimensionType): void {
explorersStore.updateTransactionExplorerFilter({
seriesDimension: seriesDimension,
});
}
function updateValueMetric(valueMetric: TransactionExplorerValueMetricType): void {
explorersStore.updateTransactionExplorerFilter({
valueMetric: valueMetric,
});
}
function updateChartSortingType(sortingType: number): void {
explorersStore.updateTransactionExplorerFilter({
chartSortingType: sortingType,
});
}
function onClickPieChartItem(item: Record<string, unknown>): void {
if (!item || !('id' in item) || !('dimension' in item)) {
return;
@@ -367,8 +335,8 @@ function onClickPieChartItem(item: Record<string, unknown>): void {
}
function buildExportResults(): { headers: string[], data: string[][] } | undefined {
if (currentChartType.value === TransactionExplorerChartType.Pie.value || currentChartType.value === TransactionExplorerChartType.Radar.value) {
const valueMetric = TransactionExplorerValueMetric.valueOf(explorersStore.transactionExplorerFilter.valueMetric);
if (currentExplorer.value.chartType === TransactionExplorerChartType.Pie.value || currentExplorer.value.chartType === TransactionExplorerChartType.Radar.value) {
const valueMetric = TransactionExplorerValueMetric.valueOf(currentExplorer.value.valueMetric);
return {
headers: [
@@ -1,14 +1,33 @@
<template>
<v-card-text class="px-5 py-0 mb-4">
<v-row>
<v-col cols="12">
<div class="d-flex overflow-x-auto align-center gap-2 pt-2">
<v-select
class="flex-0-0"
min-width="150"
item-title="name"
item-value="value"
density="compact"
:disabled="loading || disabled"
:label="tt('Transactions Per Page')"
:items="allPageCounts"
v-model="countPerPage"
/>
</div>
</v-col>
</v-row>
</v-card-text>
<v-data-table
fixed-header
fixed-footer
multi-sort
item-value="index"
:class="{ 'insights-explorer-table': true, 'text-sm': true, 'disabled': loading, 'loading-skeleton': loading }"
:class="{ 'insights-explorer-table': true, 'text-sm': true, 'disabled': loading || disabled, 'loading-skeleton': loading }"
:headers="dataTableHeaders"
:items="filteredTransactions"
:hover="true"
v-model:items-per-page="itemsPerPage"
v-model:items-per-page="countPerPage"
v-model:page="currentPage"
>
<template #item.time="{ item }">
@@ -61,7 +80,7 @@
</div>
</template>
<template #item.operation="{ item }">
<v-btn density="compact" variant="text" color="default" :disabled="loading"
<v-btn density="compact" variant="text" color="default" :disabled="loading || disabled"
@click="showTransaction(item)">
{{ tt('View') }}
</v-btn>
@@ -78,7 +97,7 @@
</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"
<pagination-buttons :disabled="loading || disabled"
:totalPageCount="totalPageCount"
v-model="currentPage">
</pagination-buttons>
@@ -98,6 +117,7 @@ import { useSettingsStore } from '@/stores/setting.ts';
import { useUserStore } from '@/stores/user.ts';
import { useExplorersStore } from '@/stores/explorer.ts';
import type { NameNumeralValue } from '@/core/base.ts';
import type { NumeralSystem } from '@/core/numeral.ts';
import { TransactionType } from '@/core/transaction.ts';
@@ -121,13 +141,13 @@ import {
interface InsightsExplorerDataTableTabProps {
loading?: boolean;
countPerPage: number;
disabled?: boolean;
}
const props = defineProps<InsightsExplorerDataTableTabProps>();
defineProps<InsightsExplorerDataTableTabProps>();
const emit = defineEmits<{
(e: 'click:transaction', value: TransactionInsightDataItem): void;
(e: 'update:countPerPage', value: number): void;
}>();
const {
@@ -144,21 +164,30 @@ const userStore = useUserStore();
const explorersStore = useExplorersStore();
const currentPage = ref<number>(1);
const countPerPage = ref<number>(15);
const numeralSystem = computed<NumeralSystem>(() => getCurrentNumeralSystemType());
const defaultCurrency = computed<string>(() => userStore.currentUserDefaultCurrency);
const filteredTransactions = computed<TransactionInsightDataItem[]>(() => explorersStore.filteredTransactions);
const itemsPerPage = computed<number>({
get: () => props.countPerPage,
set: (value: number) => emit('update:countPerPage', value)
})
const allPageCounts = computed<NameNumeralValue[]>(() => {
const pageCounts: NameNumeralValue[] = [];
const availableCountPerPage: number[] = [ 5, 10, 15, 20, 25, 30, 50 ];
for (const count of availableCountPerPage) {
pageCounts.push({ value: count, name: numeralSystem.value.formatNumber(count) });
}
pageCounts.push({ value: -1, name: tt('All') });
return pageCounts;
});
const skeletonData = computed<number[]>(() => {
const data: number[] = [];
for (let i = 0; i < itemsPerPage.value; i++) {
for (let i = 0; i < countPerPage.value; i++) {
data.push(i);
}
@@ -171,7 +200,7 @@ const totalPageCount = computed<number>(() => {
}
const count = filteredTransactions.value.length;
return Math.ceil(count / itemsPerPage.value);
return Math.ceil(count / countPerPage.value);
});
const dataTableHeaders = computed<object[]>(() => {
@@ -2,11 +2,11 @@
<v-card-subtitle class="px-5">
<div class="title-and-toolbar d-flex">
<v-btn color="default" variant="outlined"
:disabled="loading || !!editingQuery"
:disabled="loading || disabled || !!editingQuery"
@click="addQuery">{{ tt('Add Query') }}</v-btn>
<v-spacer />
<v-btn color="secondary" variant="tonal"
:disabled="loading || !!editingQuery || queries.length < 1"
:disabled="loading || disabled || !!editingQuery || queries.length < 1"
@click="clearAllQueries">{{ tt('Clear All') }}</v-btn>
</div>
</v-card-subtitle>
@@ -18,7 +18,7 @@
<span class="query-name text-subtitle-1 ms-2" v-if="editingQuery !== query">{{ query.name || tt('format.misc.queryIndex', { index: queryIndex + 1 }) }}</span>
<div class="query-name-edit ms-2" v-if="editingQuery === query">
<v-text-field autofocus type="text" density="compact" variant="underlined"
:disabled="loading"
:disabled="loading || disabled"
:placeholder="tt('format.misc.queryIndex', { index: queryIndex + 1 })"
v-text-field-auto-width="{ minWidth: 20, maxWidth: 300, auxSpanId: `query-name-aux-span-${queryIndex + 1}` }"
v-model="editingQueryName"
@@ -27,28 +27,28 @@
<span :id="`query-name-aux-span-${queryIndex + 1}`" />
</div>
<v-btn class="ms-2" density="compact" color="primary" variant="text" size="small"
:icon="true" :disabled="loading"
:icon="true" :disabled="loading || disabled"
@click="updateQueryName(query)"
v-if="editingQuery === query">
<v-icon :icon="mdiCheck" size="18" />
<v-tooltip activator="parent">{{ tt('Update') }}</v-tooltip>
</v-btn>
<v-btn class="ms-2" density="compact" color="default" variant="text" size="small"
:icon="true" :disabled="loading"
:icon="true" :disabled="loading || disabled"
@click="cancelUpdateQueryName"
v-if="editingQuery === query">
<v-icon :icon="mdiClose" size="18" />
<v-tooltip activator="parent">{{ tt('Cancel') }}</v-tooltip>
</v-btn>
<v-btn class="ms-2" density="compact" color="default" variant="text" size="small"
:icon="true" :disabled="loading || !!editingQuery"
:icon="true" :disabled="loading || disabled || !!editingQuery"
@click="editingQueryName = query.name; editingQuery = query"
v-if="!editingQuery || editingQuery !== query">
<v-icon :icon="mdiPencilOutline" size="18" />
<v-tooltip activator="parent">{{ tt('Modify Query Name') }}</v-tooltip>
</v-btn>
<v-btn class="ms-2" density="compact" color="default" variant="text" size="small"
:icon="true" :disabled="loading || !!editingQuery"
:icon="true" :disabled="loading || disabled || !!editingQuery"
@click="duplicateQuery(query)"
v-if="!editingQuery || editingQuery !== query">
<v-icon :icon="mdiContentCopy" size="18" />
@@ -56,7 +56,7 @@
</v-btn>
<v-spacer />
<v-switch class="bidirectional-switch ms-2" color="secondary"
:disabled="loading || !!editingQuery || !query.conditions || query.conditions.length < 1"
:disabled="loading || disabled || !!editingQuery || !query.conditions || query.conditions.length < 1"
:label="tt('Expression')"
v-model="showExpression[queryIndex]"
@click="showExpression[queryIndex] = !showExpression[queryIndex]">
@@ -65,7 +65,7 @@
</template>
</v-switch>
<v-btn class="ms-2" density="compact" color="default" variant="text" size="small"
:icon="true" :disabled="loading || !!editingQuery || queries.length < 1"
:icon="true" :disabled="loading || disabled || !!editingQuery || queries.length < 1"
@click="removeQuery(queryIndex)">
<v-icon :icon="mdiClose" size="18" />
<v-tooltip activator="parent">{{ tt('Remove Query') }}</v-tooltip>
@@ -102,7 +102,7 @@
density="compact"
item-title="displayName"
item-value="value"
:disabled="loading || !!editingQuery"
:disabled="loading || disabled || !!editingQuery"
:items="[
{ value: TransactionExplorerConditionRelation.And, displayName: tt('AND') },
{ value: TransactionExplorerConditionRelation.Or, displayName: tt('OR') }
@@ -116,7 +116,7 @@
density="compact"
item-title="name"
item-value="value"
:disabled="loading || !!editingQuery"
:disabled="loading || disabled || !!editingQuery"
:items="allTransactionExplorerConditionFields"
@update:model-value="updateConditionField(queryIndex, conditionIndex, TransactionExplorerConditionField.valueOf($event))"
v-model="conditionWithRelation.condition.field"
@@ -127,7 +127,7 @@
density="compact"
item-title="name"
item-value="value"
:disabled="loading || !!editingQuery"
:disabled="loading || disabled || !!editingQuery"
:items="getAllTransactionExplorerConditionOperators(conditionWithRelation.getSupportedOperators())"
v-model="conditionWithRelation.condition.operator"
/>
@@ -138,7 +138,7 @@
density="compact"
item-title="displayName"
item-value="type"
:disabled="loading || !!editingQuery"
:disabled="loading || disabled || !!editingQuery"
:placeholder="tt('None')"
:items="[
{ type: TransactionType.Expense, displayName: tt('Expense') },
@@ -166,7 +166,7 @@
item-value="type"
persistent-placeholder
:readonly="true"
:disabled="loading || !!editingQuery || !hasAnyTransactionCategory"
:disabled="loading || disabled || !!editingQuery || !hasAnyTransactionCategory"
:placeholder="tt('None')"
:model-value="getFilteredTransactionCategoriesDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
@click="currentCondition = conditionWithRelation.condition; showFilterTransactionCategoriesDialog = true"
@@ -180,7 +180,7 @@
item-value="type"
persistent-placeholder
:readonly="true"
:disabled="loading || !!editingQuery || !hasAnyAccount"
:disabled="loading || disabled || !!editingQuery || !hasAnyAccount"
:placeholder="tt('None')"
:model-value="getFilteredAccountsDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
@click="currentCondition = conditionWithRelation.condition; showFilterSourceAccountsDialog = true"
@@ -194,7 +194,7 @@
item-value="type"
persistent-placeholder
:readonly="true"
:disabled="loading || !!editingQuery || !hasAnyAccount"
:disabled="loading || disabled || !!editingQuery || !hasAnyAccount"
:placeholder="tt('None')"
:model-value="getFilteredAccountsDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
@click="currentCondition = conditionWithRelation.condition; showFilterDestinationAccountsDialog = true"
@@ -206,7 +206,7 @@
conditionWithRelation.condition.field === TransactionExplorerConditionField.DestinationAmount.value">
<amount-input density="compact"
:currency="defaultCurrency"
:disabled="loading || !!editingQuery"
:disabled="loading || disabled || !!editingQuery"
v-model="conditionWithRelation.condition.value[0]"
/>
<span class="ms-2 me-2"
@@ -214,7 +214,7 @@
conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.NotBetween.value">~</span>
<amount-input density="compact"
:currency="defaultCurrency"
:disabled="loading || !!editingQuery"
:disabled="loading || disabled || !!editingQuery"
v-model="conditionWithRelation.condition.value[1]"
v-if="conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.Between.value ||
conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.NotBetween.value"
@@ -245,7 +245,7 @@
multiple
chips
closable-chips
:disabled="loading || !!editingQuery"
:disabled="loading || disabled || !!editingQuery"
:placeholder="tt('None')"
:items="allTags"
v-model="conditionWithRelation.condition.value"
@@ -300,7 +300,7 @@
/>
<v-text-field density="compact"
:disabled="loading || !!editingQuery"
:disabled="loading || disabled || !!editingQuery"
:placeholder="tt('None')"
v-model="conditionWithRelation.condition.value"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.Description.value &&
@@ -311,7 +311,7 @@
<v-btn color="default" density="compact"
variant="text" size="small"
:icon="true"
:disabled="loading || !!editingQuery"
:disabled="loading || disabled || !!editingQuery"
@click="removeCondition(queryIndex, conditionIndex)">
<v-icon :icon="mdiClose" size="18" />
<v-tooltip activator="parent">{{ tt('Remove Condition') }}</v-tooltip>
@@ -328,7 +328,7 @@
<v-btn class="px-2" density="comfortable" color="primary" variant="text" size="small"
:prepend-icon="mdiPlus"
:disabled="loading || !!editingQuery || showExpression[queryIndex]"
:disabled="loading || disabled || !!editingQuery || showExpression[queryIndex]"
@click="addCondition(queryIndex)">
{{ tt('Add Condition') }}
</v-btn>
@@ -421,6 +421,7 @@ import {
interface ExplorerQueryTabProps {
loading?: boolean;
disabled?: boolean;
}
type SnackBarType = InstanceType<typeof SnackBar>;
@@ -451,7 +452,7 @@ const tagSearchContent = ref<string>('');
const editingQuery = ref<TransactionExplorerQuery | undefined>(undefined);
const editingQueryName = ref<string>('');
const queries = computed<TransactionExplorerQuery[]>(() => explorersStore.transactionExplorerFilter.query);
const queries = computed<TransactionExplorerQuery[]>(() => explorersStore.currentInsightsExplorer.queries);
const defaultCurrency = computed<string>(() => userStore.currentUserDefaultCurrency);
const hasAnyAccount = computed<boolean>(() => accountsStore.allPlainAccounts.length > 0);