add insights & explore page

This commit is contained in:
MaysWind
2025-12-18 00:49:14 +08:00
parent 861e4c036b
commit e9b4392163
43 changed files with 3579 additions and 43 deletions
@@ -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>