From f3ccd3b66dbab2a24bb405469dca576536ccca3f Mon Sep 17 00:00:00 2001 From: MaysWind Date: Sun, 2 Nov 2025 00:28:22 +0800 Subject: [PATCH] show total income and total expense in categorical overview chart --- .../desktop/AccountAndCategorySankeyChart.vue | 437 ++++++------------ src/models/transaction.ts | 35 ++ src/stores/statistics.ts | 327 ++++++++++++- .../StatisticsTransactionPageBase.ts | 6 +- .../desktop/statistics/TransactionPage.vue | 34 +- 5 files changed, 523 insertions(+), 316 deletions(-) diff --git a/src/components/desktop/AccountAndCategorySankeyChart.vue b/src/components/desktop/AccountAndCategorySankeyChart.vue index f08077ce..9ee3f41d 100644 --- a/src/components/desktop/AccountAndCategorySankeyChart.vue +++ b/src/components/desktop/AccountAndCategorySankeyChart.vue @@ -14,33 +14,31 @@ import { useI18n } from '@/locales/helpers.ts'; import { useUserStore } from '@/stores/user.ts'; -import type { - SortableTransactionStatisticDataItem, - TransactionStatisticResponseItemWithInfo +import { + type TransactionCategoricalOverviewAnalysisDataItem, + type TransactionCategoricalOverviewAnalysisDataItemOutflowItem, + TransactionCategoricalOverviewAnalysisDataItemType } from '@/models/transaction.ts'; -import type { Account } from '@/models/account.ts'; import { values } from '@/core/base.ts'; import { ThemeType } from '@/core/theme.ts'; -import { CategoryType } from '@/core/category.ts'; -import { TransactionRelatedAccountType } from '@/core/transaction.ts'; import { isNumber } from '@/lib/common.ts'; -import { sortStatisticsItems } from '@/lib/statistics.ts'; import { getExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts'; enum SankeyChartDepth { PrimaryIncomeCategory = 0, SecondaryIncomeCategory = 1, - Account = 2, - AccountWithTransfer = 3, + AccountForIncome = 2, + AccountForExpense = 3, SecondaryExpenseCategory = 4, PrimaryExpenseCategory = 5 } enum SankeyChartNodeItemType { Account = 'account', - Category = 'category' + Category = 'category', + NetCashFlow = 'netCashFlow' } interface SankeyChartData { @@ -48,16 +46,20 @@ interface SankeyChartData { links: SankeyChartLinkItem[]; } -interface SankeyChartNodeItem extends SortableTransactionStatisticDataItem { +interface SankeyChartNodeItem { + dateItemType: TransactionCategoricalOverviewAnalysisDataItemType; itemType: SankeyChartNodeItemType; itemId: string; name: string; - nameId: string; displayName: string; - displayOrders: number[]; totalAmount: number; + accountNetCashFlow?: number; percent?: number; depth: number; + itemStyle?: { + color?: string; + opacity?: number; + } } interface SankeyChartLinkItem { @@ -74,8 +76,7 @@ interface SankeyChartLinkItem { const props = defineProps<{ skeleton?: boolean; - items: TransactionStatisticResponseItemWithInfo[]; - sortingType: number; + items: TransactionCategoricalOverviewAnalysisDataItem[]; defaultCurrency?: string; enableClickItem?: boolean; }>(); @@ -87,230 +88,134 @@ const emit = defineEmits<{ const theme = useTheme(); const { + tt, formatAmountToLocalizedNumeralsWithCurrency, formatPercentToLocalizedNumerals } = useI18n(); const userStore = useUserStore(); +const overviewDataItemTypeSankeyChartNodeItemTypeMap: Record = { + [TransactionCategoricalOverviewAnalysisDataItemType.IncomeByPrimaryCategory]: SankeyChartNodeItemType.Category, + [TransactionCategoricalOverviewAnalysisDataItemType.IncomeBySecondaryCategory]: SankeyChartNodeItemType.Category, + [TransactionCategoricalOverviewAnalysisDataItemType.IncomeByAccount]: SankeyChartNodeItemType.Account, + [TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByAccount]: SankeyChartNodeItemType.Account, + [TransactionCategoricalOverviewAnalysisDataItemType.NetCashFlow]: SankeyChartNodeItemType.NetCashFlow, + [TransactionCategoricalOverviewAnalysisDataItemType.ExpenseBySecondaryCategory]: SankeyChartNodeItemType.Category, + [TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByPrimaryCategory]: SankeyChartNodeItemType.Category +}; + +const overviewDataItemTypeSankeyChartNodeItemDepthMap: Record = { + [TransactionCategoricalOverviewAnalysisDataItemType.IncomeByPrimaryCategory]: SankeyChartDepth.PrimaryIncomeCategory, + [TransactionCategoricalOverviewAnalysisDataItemType.IncomeBySecondaryCategory]: SankeyChartDepth.SecondaryIncomeCategory, + [TransactionCategoricalOverviewAnalysisDataItemType.IncomeByAccount]: SankeyChartDepth.AccountForIncome, + [TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByAccount]: SankeyChartDepth.AccountForExpense, + [TransactionCategoricalOverviewAnalysisDataItemType.NetCashFlow]: SankeyChartDepth.SecondaryExpenseCategory, + [TransactionCategoricalOverviewAnalysisDataItemType.ExpenseBySecondaryCategory]: SankeyChartDepth.SecondaryExpenseCategory, + [TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByPrimaryCategory]: SankeyChartDepth.PrimaryExpenseCategory +}; + const isDarkMode = computed(() => theme.global.name.value === ThemeType.Dark); const sankeyData = computed(() => { - const primaryIncomeCategoryNodesMap: Record = {}; - const secondaryIncomeCategoryNodesMap: Record = {}; - const incomeAccountNodesMap: Record = {}; - const expenseAccountNodesMap: Record = {}; - const secondaryExpenseCategoryNodesMap: Record = {}; - const primaryExpenseCategoryNodesMap: Record = {}; - const linksMap: Record = {}; - const accountsMap: Record = {}; + const nodes: SankeyChartNodeItem[] = []; + const links: SankeyChartLinkItem[] = []; for (const item of props.items) { - if (!item.primaryAccount || !item.account || !item.primaryCategory || !item.category || !item.amountInDefaultCurrency) { + if (item.hidden) { continue; } - if (item.account.hidden || item.primaryAccount.hidden || item.category.hidden || item.primaryCategory.hidden) { + const itemType = overviewDataItemTypeSankeyChartNodeItemTypeMap[item.type]; + const depth = overviewDataItemTypeSankeyChartNodeItemDepthMap[item.type]; + + if (!itemType || itemType === SankeyChartNodeItemType.NetCashFlow || depth === undefined) { continue; } - if (item.relatedAccount && (item.relatedAccountType === TransactionRelatedAccountType.TransferFrom || item.relatedAccount.hidden || !item.relatedPrimaryAccount || item.relatedPrimaryAccount.hidden)) { + if (item.totalAmount === 0 && item.outflows.length === 0) { continue; } - const incomeAccountNameId = `income_account:${item.account.id}`; - const expenseAccountNameId = `expense_account:${item.account.id}`; - accountsMap[item.account.id] = item.account; + const nodeItem: SankeyChartNodeItem = { + dateItemType: item.type, + itemType: itemType, + itemId: item.id, + name: `${item.type}:${item.id}`, + displayName: item.name, + totalAmount: item.totalAmount, + percent: item.percent, + depth: depth + }; - updateNodeItem(incomeAccountNodesMap, { - itemType: SankeyChartNodeItemType.Account, - id: item.account.id, - name: item.account.name, - nameId: incomeAccountNameId, - displayOrders: [item.account.displayOrder], - amount: item.primaryCategory.type == CategoryType.Income ? item.amountInDefaultCurrency : 0, - depth: SankeyChartDepth.Account - }); - - updateNodeItem(expenseAccountNodesMap, { - itemType: SankeyChartNodeItemType.Account, - id: item.account.id, - name: item.account.name, - nameId: expenseAccountNameId, - displayOrders: [item.account.displayOrder], - amount: item.primaryCategory.type == CategoryType.Expense ? item.amountInDefaultCurrency : 0, - depth: SankeyChartDepth.AccountWithTransfer - }); - - if (item.primaryCategory.type == CategoryType.Income) { - updateNodeItem(primaryIncomeCategoryNodesMap, { - itemType: SankeyChartNodeItemType.Category, - id: item.primaryCategory.id, - name: item.primaryCategory.name, - nameId: item.primaryCategory.id, - displayOrders: [item.primaryCategory.displayOrder], - amount: item.amountInDefaultCurrency, - depth: SankeyChartDepth.PrimaryIncomeCategory - }); - - updateNodeItem(secondaryIncomeCategoryNodesMap, { - itemType: SankeyChartNodeItemType.Category, - id: item.category.id, - name: item.category.name, - nameId: item.category.id, - displayOrders: [item.primaryCategory.displayOrder, item.category.displayOrder], - amount: item.amountInDefaultCurrency, - depth: SankeyChartDepth.SecondaryIncomeCategory - }); - - updateLinkItem(linksMap, { - sourceItemType: SankeyChartNodeItemType.Category, - sourceItemId: item.primaryCategory.id, - source: item.primaryCategory.id, - sourceName: item.primaryCategory.name, - targetItemType: SankeyChartNodeItemType.Category, - targetItemId: item.category.id, - target: item.category.id, - targetName: item.category.name, - value: item.amountInDefaultCurrency - }); - - updateLinkItem(linksMap, { - sourceItemType: SankeyChartNodeItemType.Category, - sourceItemId: item.category.id, - source: item.category.id, - sourceName: item.category.name, - targetItemType: SankeyChartNodeItemType.Account, - targetItemId: item.account.id, - target: incomeAccountNameId, - targetName: item.account.name, - value: item.amountInDefaultCurrency - }); - } else if (item.primaryCategory.type == CategoryType.Expense) { - updateNodeItem(secondaryExpenseCategoryNodesMap, { - itemType: SankeyChartNodeItemType.Category, - id: item.category.id, - name: item.category.name, - nameId: item.category.id, - displayOrders: [item.primaryCategory.displayOrder, item.category.displayOrder], - amount: item.amountInDefaultCurrency, - depth: SankeyChartDepth.SecondaryExpenseCategory - }); - - updateNodeItem(primaryExpenseCategoryNodesMap, { - itemType: SankeyChartNodeItemType.Category, - id: item.primaryCategory.id, - name: item.primaryCategory.name, - nameId: item.primaryCategory.id, - displayOrders: [item.primaryCategory.displayOrder], - amount: item.amountInDefaultCurrency, - depth: SankeyChartDepth.PrimaryExpenseCategory - }); - - updateLinkItem(linksMap, { - sourceItemType: SankeyChartNodeItemType.Account, - sourceItemId: item.account.id, - source: expenseAccountNameId, - sourceName: item.account.name, - targetItemType: SankeyChartNodeItemType.Category, - targetItemId: item.category.id, - target: item.category.id, - targetName: item.category.name, - value: item.amountInDefaultCurrency - }); - - updateLinkItem(linksMap, { - sourceItemType: SankeyChartNodeItemType.Category, - sourceItemId: item.category.id, - source: item.category.id, - sourceName: item.category.name, - targetItemType: SankeyChartNodeItemType.Category, - targetItemId: item.primaryCategory.id, - target: item.primaryCategory.id, - targetName: item.primaryCategory.name, - value: item.amountInDefaultCurrency - }); - } else if (item.primaryCategory.type == CategoryType.Transfer && item.relatedAccount) { - const relatedAccountNameId = `expense_account:${item.relatedAccount.id}`; - accountsMap[item.relatedAccount.id] = item.relatedAccount; - updateLinkItem(linksMap, { - sourceItemType: SankeyChartNodeItemType.Account, - sourceItemId: item.account.id, - source: incomeAccountNameId, - sourceName: item.account.name, - targetItemType: SankeyChartNodeItemType.Account, - targetItemId: item.relatedAccount.id, - target: relatedAccountNameId, - targetName: item.relatedAccount.name, - value: item.amountInDefaultCurrency - }); + if (!isNumber(nodeItem.percent) && nodeItem.dateItemType === TransactionCategoricalOverviewAnalysisDataItemType.IncomeByAccount) { + nodeItem.itemStyle = { + color: '#aaa', + opacity: 0.5 + }; } - } - for (const account of values(accountsMap)) { - const incomeAccountNameId = `income_account:${account.id}`; - const expenseAccountNameId = `expense_account:${account.id}`; + if (nodeItem.dateItemType === TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByAccount) { + for (const outflowItem of item.outflows) { + if (outflowItem.relatedItem.type !== TransactionCategoricalOverviewAnalysisDataItemType.NetCashFlow) { + continue; + } - let totalOutflowAmount = 0; - let totalInflowAmount = 0; - - for (const link of values(linksMap)) { - if (link.sourceItemType === SankeyChartNodeItemType.Account && link.sourceItemId === account.id) { - totalOutflowAmount += link.value; - } else if (link.targetItemType === SankeyChartNodeItemType.Account && link.targetItemId === account.id) { - totalInflowAmount += link.value; + nodeItem.accountNetCashFlow = (nodeItem.accountNetCashFlow ?? 0) + outflowItem.amount; } } - const amountDifference = totalOutflowAmount - totalInflowAmount; + nodes.push(nodeItem); - if (amountDifference > 0) { - updateNodeItem(incomeAccountNodesMap, { - itemType: SankeyChartNodeItemType.Account, - id: account.id, - name: account.name, - nameId: incomeAccountNameId, - displayOrders: [account.displayOrder], - amount: amountDifference, - depth: SankeyChartDepth.AccountWithTransfer - }); - } else if (amountDifference < 0) { - updateNodeItem(expenseAccountNodesMap, { - itemType: SankeyChartNodeItemType.Account, - id: account.id, - name: account.name, - nameId: expenseAccountNameId, - displayOrders: [account.displayOrder], - amount: -amountDifference, - depth: SankeyChartDepth.AccountWithTransfer - }); + const combinedOutflows: Record = {}; + + for (const outflowItem of item.outflows) { + const relatedItem = outflowItem.relatedItem; + + if (!relatedItem) { + continue; + } + + if (outflowItem.relatedItem) { + const key = `${item.type}:${item.id}-${outflowItem.relatedItem.type}:${outflowItem.relatedItem.id}`; + let combinedOutflow: TransactionCategoricalOverviewAnalysisDataItemOutflowItem | undefined = combinedOutflows[key]; + + if (!combinedOutflow) { + combinedOutflow = { + relatedItem: outflowItem.relatedItem, + amount: 0 + }; + combinedOutflows[key] = combinedOutflow; + } + + combinedOutflow.amount += outflowItem.amount; + } } - if (Math.abs(amountDifference) > 0) { - updateLinkItem(linksMap, { - sourceItemType: SankeyChartNodeItemType.Account, - sourceItemId: account.id, - source: incomeAccountNameId, - sourceName: account.name, - targetItemType: SankeyChartNodeItemType.Account, - targetItemId: account.id, - target: expenseAccountNameId, - targetName: account.name, - value: Math.abs(amountDifference) - }); + for (const outflowItem of values(combinedOutflows)) { + const relatedItem = outflowItem.relatedItem; + const transferItemType = overviewDataItemTypeSankeyChartNodeItemTypeMap[relatedItem.type]; + + if (!transferItemType) { + continue; + } + + const linkItem: SankeyChartLinkItem = { + sourceItemType: itemType, + sourceItemId: item.id, + source: `${item.type}:${item.id}`, + sourceDisplayName: item.name, + targetItemType: transferItemType, + targetItemId: relatedItem.id, + target: `${relatedItem.type}:${relatedItem.id}`, + targetDisplayName: relatedItem.name, + value: outflowItem.amount + }; + + links.push(linkItem); } } - const nodes: SankeyChartNodeItem[] = []; - const links: SankeyChartLinkItem[] = []; - addFinalSortedNodeItems(primaryIncomeCategoryNodesMap, nodes); - addFinalSortedNodeItems(secondaryIncomeCategoryNodesMap, nodes); - addFinalSortedNodeItems(incomeAccountNodesMap, nodes); - addFinalSortedNodeItems(expenseAccountNodesMap, nodes); - addFinalSortedNodeItems(secondaryExpenseCategoryNodesMap, nodes); - addFinalSortedNodeItems(primaryExpenseCategoryNodesMap, nodes); - addFinalLinkItems(linksMap, links); - const ret: SankeyChartData = { nodes: nodes, links: links @@ -335,15 +240,44 @@ const chartOptions = computed(() => { const dataItem = params.data as SankeyChartNodeItem; const value = dataItem.totalAmount; const displayValue = formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency); + let displayTypeName = ''; + + if (dataItem.dateItemType === TransactionCategoricalOverviewAnalysisDataItemType.IncomeByPrimaryCategory) { + displayTypeName = tt('Income By Primary Category'); + } else if (dataItem.dateItemType === TransactionCategoricalOverviewAnalysisDataItemType.IncomeBySecondaryCategory) { + displayTypeName = tt('Income By Secondary Category'); + } else if (dataItem.dateItemType === TransactionCategoricalOverviewAnalysisDataItemType.IncomeByAccount) { + displayTypeName = tt('Income By Account'); + } else if (dataItem.dateItemType === TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByAccount) { + displayTypeName = tt('Expense By Account'); + } else if (dataItem.dateItemType === TransactionCategoricalOverviewAnalysisDataItemType.NetCashFlow) { + displayTypeName = tt('Net Cash Flow'); + } else if (dataItem.dateItemType === TransactionCategoricalOverviewAnalysisDataItemType.ExpenseBySecondaryCategory) { + displayTypeName = tt('Expense By Secondary Category'); + } else if (dataItem.dateItemType === TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByPrimaryCategory) { + displayTypeName = tt('Expense By Primary Category'); + } let tooltip = `
${dataItem.displayName}`; - if (isNumber(dataItem.percent)) { + if (displayTypeName && (dataItem.dateItemType !== TransactionCategoricalOverviewAnalysisDataItemType.IncomeByAccount || isNumber(dataItem.percent))) { + tooltip = `
${displayTypeName}
` + tooltip; + } else if (dataItem.dateItemType === TransactionCategoricalOverviewAnalysisDataItemType.IncomeByAccount) { + tooltip = `
${tt('Account Balance')}
` + tooltip; + } + + if (isNumber(dataItem.percent) && dataItem.percent > 0) { const displayPercent = formatPercentToLocalizedNumerals(dataItem.percent, 2, '<0.01'); tooltip += `(${displayPercent})`; } tooltip += `${displayValue}
`; + + if (isNumber(dataItem.accountNetCashFlow) && dataItem.accountNetCashFlow !== 0) { + const displayAccountNetCashFlow = formatAmountToLocalizedNumeralsWithCurrency(dataItem.accountNetCashFlow, props.defaultCurrency); + tooltip += `
${tt('Net Cash Flow')}${displayAccountNetCashFlow}
`; + } + return tooltip; } else if (params.dataType === 'edge') { const dataItem = params.data as SankeyChartLinkItem; @@ -359,7 +293,7 @@ const chartOptions = computed(() => { { type: 'sankey', left: 10, - top: 10, + top: 0, bottom: 10, roam: true, layoutIterations: 0, @@ -393,7 +327,7 @@ const chartOptions = computed(() => { } }, { - depth: SankeyChartDepth.Account, + depth: SankeyChartDepth.AccountForIncome, itemStyle: { color: '#c07d43', opacity: 0.5 @@ -404,7 +338,7 @@ const chartOptions = computed(() => { } }, { - depth: SankeyChartDepth.AccountWithTransfer, + depth: SankeyChartDepth.AccountForExpense, itemStyle: { color: '#c07d43', opacity: 0.5 @@ -448,78 +382,6 @@ const chartOptions = computed(() => { }; }); -function updateNodeItem(nodesMap: Record, { itemType, id, name, nameId, displayOrders, amount, depth }: { itemType: SankeyChartNodeItemType, id: string, name: string, nameId: string, displayOrders: number[], amount: number, depth: number }) { - const node: SankeyChartNodeItem | undefined = nodesMap[nameId]; - - if (!node) { - nodesMap[nameId] = { - itemType: itemType, - itemId: id, - name: name, - nameId: nameId, - displayName: name, - displayOrders: displayOrders, - totalAmount: amount, - depth: depth - }; - } else { - node.totalAmount += amount; - } -} - -function updateLinkItem(linksMap: Record, { sourceItemType, sourceItemId, source, sourceName, targetItemType, targetItemId, target, targetName, value }: { sourceItemType: SankeyChartNodeItemType, sourceItemId: string, source: string, sourceName: string, targetItemType: SankeyChartNodeItemType, targetItemId: string, target: string, targetName: string, value: number }) { - const key = `${source}:${target}`; - const link: SankeyChartLinkItem | undefined = linksMap[key]; - - if (!link) { - linksMap[key] = { - sourceItemType: sourceItemType, - sourceItemId: sourceItemId, - source: source, - sourceDisplayName: sourceName, - targetItemType: targetItemType, - targetItemId: targetItemId, - target: target, - targetDisplayName: targetName, - value: value - }; - } else { - link.value += value; - } -} - -function addFinalSortedNodeItems(nodesMap: Record, allNodesArray: SankeyChartNodeItem[]): void { - const nodesArray: SankeyChartNodeItem[] = []; - let totalAmount = 0; - - for (const node of values(nodesMap)) { - if (node.totalAmount > 0) { - totalAmount += node.totalAmount; - } - - nodesArray.push(node); - } - - sortStatisticsItems(nodesArray, props.sortingType); - - for (const node of nodesArray) { - node.name = node.nameId; - node.percent = node.totalAmount > 0 && totalAmount > 0 ? (node.totalAmount / totalAmount) * 100 : undefined; - } - - allNodesArray.push(...nodesArray); -} - -function addFinalLinkItems(linksMap: Record, allLinksArray: SankeyChartLinkItem[]): void { - const linksArray: SankeyChartLinkItem[] = []; - - for (const link of values(linksMap)) { - linksArray.push(link); - } - - allLinksArray.push(...linksArray); -} - function clickItem(e: ECElementEvent): void { if (!props.enableClickItem || e.componentType !== 'series' || e.seriesType !=='sankey') { return; @@ -531,13 +393,22 @@ function clickItem(e: ECElementEvent): void { if (e.dataType === 'node') { const dataItem = e.data as SankeyChartNodeItem; + + if (dataItem.itemType === SankeyChartNodeItemType.NetCashFlow) { + return; + } + emit('click', dataItem.itemType, dataItem.itemId); } else if (e.dataType === 'edge') { const dataItem = e.data as SankeyChartLinkItem; + if (dataItem.sourceItemType === SankeyChartNodeItemType.NetCashFlow) { + return; + } + if (dataItem.sourceItemType === dataItem.targetItemType && dataItem.sourceItemId === dataItem.targetItemId) { emit('click', dataItem.sourceItemType, dataItem.sourceItemId); - } else { + } else if (dataItem.targetItemType !== SankeyChartNodeItemType.NetCashFlow) { emit('click', dataItem.sourceItemType, dataItem.sourceItemId, dataItem.targetItemType, dataItem.targetItemId); } } diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 8eb3ef83..ec2e60c7 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -762,6 +762,41 @@ export interface TransactionStatisticDataItemBase extends SortableTransactionSta readonly totalAmount: number; } +export interface TransactionCategoricalOverviewAnalysisData { + readonly totalIncome: number; + readonly totalExpense: number; + readonly items: TransactionCategoricalOverviewAnalysisDataItem[]; +} + +export enum TransactionCategoricalOverviewAnalysisDataItemType { + IncomeByPrimaryCategory = 'incomeByPrimaryCategory', + IncomeBySecondaryCategory = 'incomeBySecondaryCategory', + IncomeByAccount = 'incomeByAccount', + ExpenseByAccount = 'expenseByAccount', + NetCashFlow = 'netCashFlow', + ExpenseBySecondaryCategory = 'expenseBySecondaryCategory', + ExpenseByPrimaryCategory = 'expenseByPrimaryCategory' +} + +export interface TransactionCategoricalOverviewAnalysisDataItem extends SortableTransactionStatisticDataItem { + readonly id: string; + readonly name: string; + readonly type: TransactionCategoricalOverviewAnalysisDataItemType; + readonly displayOrders: number[]; + readonly hidden: boolean; + readonly inflows: TransactionCategoricalOverviewAnalysisDataItemOutflowItem[]; + readonly outflows: TransactionCategoricalOverviewAnalysisDataItemOutflowItem[]; + totalAmount: number; + totalNonNegativeAmount: number; + includeInPercent?: boolean; + percent?: number; +} + +export interface TransactionCategoricalOverviewAnalysisDataItemOutflowItem { + readonly relatedItem: TransactionCategoricalOverviewAnalysisDataItem; + amount: number; +} + export interface TransactionCategoricalAnalysisData { readonly totalAmount: number; readonly items: TransactionCategoricalAnalysisDataItem[]; diff --git a/src/stores/statistics.ts b/src/stores/statistics.ts index 45e01c46..b663e5b0 100644 --- a/src/stores/statistics.ts +++ b/src/stores/statistics.ts @@ -28,20 +28,23 @@ import { import { DEFAULT_ACCOUNT_ICON, DEFAULT_CATEGORY_ICON } from '@/consts/icon.ts'; import { DEFAULT_ACCOUNT_COLOR, DEFAULT_CATEGORY_COLOR } from '@/consts/color.ts'; -import type { - TransactionStatisticResponse, - TransactionStatisticResponseItem, - TransactionStatisticTrendsResponseItem, - TransactionStatisticResponseItemWithInfo, - TransactionStatisticResponseWithInfo, - TransactionStatisticTrendsResponseItemWithInfo, - TransactionStatisticDataItemType, - TransactionStatisticDataItemBase, - TransactionCategoricalAnalysisData, - TransactionCategoricalAnalysisDataItem, - TransactionTrendsAnalysisData, - TransactionTrendsAnalysisDataItem, - TransactionTrendsAnalysisDataAmount +import { + type TransactionStatisticResponse, + type TransactionStatisticResponseItem, + type TransactionStatisticTrendsResponseItem, + type TransactionStatisticResponseItemWithInfo, + type TransactionStatisticResponseWithInfo, + type TransactionStatisticTrendsResponseItemWithInfo, + type TransactionStatisticDataItemType, + type TransactionStatisticDataItemBase, + type TransactionCategoricalOverviewAnalysisData, + type TransactionCategoricalOverviewAnalysisDataItem, + type TransactionCategoricalAnalysisData, + type TransactionCategoricalAnalysisDataItem, + type TransactionTrendsAnalysisData, + type TransactionTrendsAnalysisDataItem, + type TransactionTrendsAnalysisDataAmount, + TransactionCategoricalOverviewAnalysisDataItemType } from '@/models/transaction.ts'; import { @@ -203,36 +206,276 @@ export const useStatisticsStore = defineStore('statistics', () => { return getCategoryTotalAmountItems(transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value.items, transactionStatisticsFilter.value); }); - const categoricalAllAnalysisData = computed(() => { + const categoricalOverviewAnalysisData = computed(() => { if (!transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value || !transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value.items) { return null; } - const allDataItems: TransactionStatisticResponseItemWithInfo[] = []; + const allDataItemsMap: Record = {}; + const allIncomeByPrimaryCategoryDataItems: TransactionCategoricalOverviewAnalysisDataItem[] = []; + const allIncomeBySecondaryCategoryDataItems: TransactionCategoricalOverviewAnalysisDataItem[] = []; + const allIncomeByAccountDataItems: TransactionCategoricalOverviewAnalysisDataItem[] = []; + const allExpenseByAccountDataItems: TransactionCategoricalOverviewAnalysisDataItem[] = []; + const allExpenseBySecondaryCategoryDataItems: TransactionCategoricalOverviewAnalysisDataItem[] = []; + const allExpenseByPrimaryCategoryDataItems: TransactionCategoricalOverviewAnalysisDataItem[] = []; + const allOpeningBalanceDataItems: TransactionCategoricalOverviewAnalysisDataItem[] = []; + const allNetCashFlowDataItems: TransactionCategoricalOverviewAnalysisDataItem[] = []; + + let totalIncome: number = 0; + let totalExpense: number = 0; for (const item of transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value.items) { if (!item.primaryAccount || !item.account || !item.primaryCategory || !item.category) { continue; } + if (item.relatedAccount && item.relatedAccountType === TransactionRelatedAccountType.TransferFrom) { + continue; + } + + if (!isNumber(item.amountInDefaultCurrency)) { + continue; + } + if (transactionStatisticsFilter.value.filterAccountIds && transactionStatisticsFilter.value.filterAccountIds[item.account.id]) { continue; } - if (transactionStatisticsFilter.value.filterAccountIds && item.relatedAccount && transactionStatisticsFilter.value.filterAccountIds[item.relatedAccount.id]) { - continue; - } - if (transactionStatisticsFilter.value.filterCategoryIds && transactionStatisticsFilter.value.filterCategoryIds[item.category.id]) { continue; } - allDataItems.push(item); + if (item.category.type === CategoryType.Income) { + totalIncome += item.amountInDefaultCurrency; + } else if (item.category.type === CategoryType.Expense) { + totalExpense += item.amountInDefaultCurrency; + } + + const incomeByAccountKey = `${TransactionCategoricalOverviewAnalysisDataItemType.IncomeByAccount}:${item.account.id}`; + const expenseByAccountKey = `${TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByAccount}:${item.account.id}`; + let incomeByAccountItem: TransactionCategoricalOverviewAnalysisDataItem | undefined = allDataItemsMap[incomeByAccountKey]; + let expenseByAccountItem: TransactionCategoricalOverviewAnalysisDataItem | undefined = allDataItemsMap[expenseByAccountKey]; + + if (!incomeByAccountItem) { + incomeByAccountItem = createNewTransactionCategoricalOverviewAnalysisDataItem( + item.account.id, + item.account.name, + TransactionCategoricalOverviewAnalysisDataItemType.IncomeByAccount, + [item.primaryAccount.category, item.primaryAccount.displayOrder, item.account.displayOrder], + item.primaryAccount.hidden || item.account.hidden); + allDataItemsMap[incomeByAccountKey] = incomeByAccountItem; + allIncomeByAccountDataItems.push(incomeByAccountItem); + } + + if (!expenseByAccountItem) { + expenseByAccountItem = createNewTransactionCategoricalOverviewAnalysisDataItem( + item.account.id, + item.account.name, + TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByAccount, + [item.primaryAccount.category, item.primaryAccount.displayOrder, item.account.displayOrder], + item.primaryAccount.hidden || item.account.hidden); + allDataItemsMap[expenseByAccountKey] = expenseByAccountItem; + allExpenseByAccountDataItems.push(expenseByAccountItem); + } + + if (item.category.type === CategoryType.Income) { + const primaryCategoryItemKey = `${TransactionCategoricalOverviewAnalysisDataItemType.IncomeByPrimaryCategory}:${item.primaryCategory.id}`; + const secondaryCategoryItemKey = `${TransactionCategoricalOverviewAnalysisDataItemType.IncomeBySecondaryCategory}:${item.category.id}`; + + let primaryCategoryDataItem: TransactionCategoricalOverviewAnalysisDataItem | undefined = allDataItemsMap[primaryCategoryItemKey]; + let secondaryCategoryDataItem: TransactionCategoricalOverviewAnalysisDataItem | undefined = allDataItemsMap[secondaryCategoryItemKey]; + + if (!primaryCategoryDataItem) { + primaryCategoryDataItem = createNewTransactionCategoricalOverviewAnalysisDataItem( + item.primaryCategory.id, + item.primaryCategory.name, + TransactionCategoricalOverviewAnalysisDataItemType.IncomeByPrimaryCategory, + [item.primaryCategory.displayOrder], + item.primaryCategory.hidden); + allDataItemsMap[primaryCategoryItemKey] = primaryCategoryDataItem; + allIncomeByPrimaryCategoryDataItems.push(primaryCategoryDataItem); + } + + if (!secondaryCategoryDataItem) { + secondaryCategoryDataItem = createNewTransactionCategoricalOverviewAnalysisDataItem( + item.category.id, + item.category.name, + TransactionCategoricalOverviewAnalysisDataItemType.IncomeBySecondaryCategory, + [item.primaryCategory.displayOrder, item.category.displayOrder], + item.primaryCategory.hidden || item.category.hidden); + allDataItemsMap[secondaryCategoryItemKey] = secondaryCategoryDataItem; + allIncomeBySecondaryCategoryDataItems.push(secondaryCategoryDataItem); + } + + primaryCategoryDataItem.totalAmount += item.amountInDefaultCurrency; + primaryCategoryDataItem.totalNonNegativeAmount += item.amountInDefaultCurrency > 0 ? item.amountInDefaultCurrency : 0; + primaryCategoryDataItem.includeInPercent = true; + primaryCategoryDataItem.outflows.push({ amount: item.amountInDefaultCurrency, relatedItem: secondaryCategoryDataItem }); + + secondaryCategoryDataItem.totalAmount += item.amountInDefaultCurrency; + secondaryCategoryDataItem.totalNonNegativeAmount += item.amountInDefaultCurrency > 0 ? item.amountInDefaultCurrency : 0; + secondaryCategoryDataItem.includeInPercent = true; + secondaryCategoryDataItem.inflows.push({ amount: item.amountInDefaultCurrency, relatedItem: primaryCategoryDataItem }); + secondaryCategoryDataItem.outflows.push({ amount: item.amountInDefaultCurrency, relatedItem: incomeByAccountItem }); + + incomeByAccountItem.totalAmount += item.amountInDefaultCurrency; + incomeByAccountItem.totalNonNegativeAmount += item.amountInDefaultCurrency > 0 ? item.amountInDefaultCurrency : 0; + incomeByAccountItem.includeInPercent = true; + incomeByAccountItem.inflows.push({ amount: item.amountInDefaultCurrency, relatedItem: secondaryCategoryDataItem }); + } else if (item.category.type === CategoryType.Expense) { + const primaryCategoryItemKey = `${TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByPrimaryCategory}:${item.primaryCategory.id}`; + const secondaryCategoryItemKey = `${TransactionCategoricalOverviewAnalysisDataItemType.ExpenseBySecondaryCategory}:${item.category.id}`; + + let primaryCategoryDataItem: TransactionCategoricalOverviewAnalysisDataItem | undefined = allDataItemsMap[primaryCategoryItemKey]; + let secondaryCategoryDataItem: TransactionCategoricalOverviewAnalysisDataItem | undefined = allDataItemsMap[secondaryCategoryItemKey]; + + if (!primaryCategoryDataItem) { + primaryCategoryDataItem = createNewTransactionCategoricalOverviewAnalysisDataItem( + item.primaryCategory.id, + item.primaryCategory.name, + TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByPrimaryCategory, + [item.primaryCategory.displayOrder], + item.primaryCategory.hidden); + allDataItemsMap[primaryCategoryItemKey] = primaryCategoryDataItem; + allExpenseByPrimaryCategoryDataItems.push(primaryCategoryDataItem); + } + + if (!secondaryCategoryDataItem) { + secondaryCategoryDataItem = createNewTransactionCategoricalOverviewAnalysisDataItem( + item.category.id, + item.category.name, + TransactionCategoricalOverviewAnalysisDataItemType.ExpenseBySecondaryCategory, + [item.primaryCategory.displayOrder, item.category.displayOrder], + item.primaryCategory.hidden || item.category.hidden); + allDataItemsMap[secondaryCategoryItemKey] = secondaryCategoryDataItem; + allExpenseBySecondaryCategoryDataItems.push(secondaryCategoryDataItem); + } + + expenseByAccountItem.totalAmount += item.amountInDefaultCurrency; + expenseByAccountItem.totalNonNegativeAmount += item.amountInDefaultCurrency > 0 ? item.amountInDefaultCurrency : 0; + expenseByAccountItem.includeInPercent = true; + expenseByAccountItem.outflows.push({ amount: item.amountInDefaultCurrency, relatedItem: secondaryCategoryDataItem }); + + secondaryCategoryDataItem.totalAmount += item.amountInDefaultCurrency; + secondaryCategoryDataItem.totalNonNegativeAmount += item.amountInDefaultCurrency > 0 ? item.amountInDefaultCurrency : 0 + secondaryCategoryDataItem.includeInPercent = true; + secondaryCategoryDataItem.inflows.push({ amount: item.amountInDefaultCurrency, relatedItem: expenseByAccountItem }); + secondaryCategoryDataItem.outflows.push({ amount: item.amountInDefaultCurrency, relatedItem: primaryCategoryDataItem }); + + primaryCategoryDataItem.totalAmount += item.amountInDefaultCurrency; + primaryCategoryDataItem.totalNonNegativeAmount += item.amountInDefaultCurrency > 0 ? item.amountInDefaultCurrency : 0; + primaryCategoryDataItem.includeInPercent = true; + primaryCategoryDataItem.inflows.push({ amount: item.amountInDefaultCurrency, relatedItem: secondaryCategoryDataItem }); + } else if (item.category.type === CategoryType.Transfer && item.relatedPrimaryAccount && item.relatedAccount) { + const transferToAccountKey = `${TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByAccount}:${item.relatedAccount.id}`; + let transferToAccountItem: TransactionCategoricalOverviewAnalysisDataItem | undefined = allDataItemsMap[transferToAccountKey]; + + if (!transferToAccountItem) { + transferToAccountItem = createNewTransactionCategoricalOverviewAnalysisDataItem( + item.relatedAccount.id, + item.relatedAccount.name, + TransactionCategoricalOverviewAnalysisDataItemType.ExpenseByAccount, + [item.relatedPrimaryAccount.category, item.relatedPrimaryAccount.displayOrder, item.relatedAccount.displayOrder], + item.relatedPrimaryAccount.hidden || item.relatedAccount.hidden); + allDataItemsMap[transferToAccountKey] = transferToAccountItem; + allExpenseByAccountDataItems.push(transferToAccountItem); + } + + incomeByAccountItem.outflows.push({ amount: item.amountInDefaultCurrency, relatedItem: transferToAccountItem }); + transferToAccountItem.inflows.push({ amount: item.amountInDefaultCurrency, relatedItem: incomeByAccountItem }); + } } + sortCategoricalOverviewAnalysisDataItems(allIncomeByPrimaryCategoryDataItems, transactionStatisticsFilter.value); + sortCategoricalOverviewAnalysisDataItems(allIncomeBySecondaryCategoryDataItems, transactionStatisticsFilter.value); + sortCategoricalOverviewAnalysisDataItems(allIncomeByAccountDataItems, transactionStatisticsFilter.value); + sortCategoricalOverviewAnalysisDataItems(allExpenseByAccountDataItems, transactionStatisticsFilter.value); + sortCategoricalOverviewAnalysisDataItems(allExpenseBySecondaryCategoryDataItems, transactionStatisticsFilter.value); + sortCategoricalOverviewAnalysisDataItems(allExpenseByPrimaryCategoryDataItems, transactionStatisticsFilter.value); + + for (const item of allExpenseByAccountDataItems) { + const incomeByAccountKey = `${TransactionCategoricalOverviewAnalysisDataItemType.IncomeByAccount}:${item.id}`; + const incomeByAccountItem: TransactionCategoricalOverviewAnalysisDataItem | undefined = allDataItemsMap[incomeByAccountKey]; + + let accountTotalInflowsAmount: number = 0; + let accountTotalIncomeAmount: number = 0; + let accountTotalTransferAmount: number = 0; + let accountTotalOutflowsAmount: number = 0; + + if (incomeByAccountItem) { + for (const inflow of incomeByAccountItem.inflows) { + accountTotalInflowsAmount += inflow.amount; + accountTotalIncomeAmount += inflow.amount; + } + + for (const outflow of incomeByAccountItem.outflows) { + accountTotalTransferAmount += outflow.amount; + } + } + + for (const inflow of item.inflows) { + if (inflow.relatedItem.type === item.type && inflow.relatedItem.id === item.id) { + continue; + } + + accountTotalInflowsAmount += inflow.amount; + } + + for (const outflow of item.outflows) { + accountTotalOutflowsAmount += outflow.amount; + } + + const accountBalance: number = accountTotalIncomeAmount - accountTotalTransferAmount - accountTotalOutflowsAmount; + const accountNetCashFlow: number = accountTotalInflowsAmount - accountTotalTransferAmount - accountTotalOutflowsAmount; + + if (incomeByAccountItem && accountsStore.allAccountsMap[item.id]?.isAsset) { + if (accountBalance > 0) { // has positive balance, transfer the amount from income account to expense account + incomeByAccountItem.outflows.push({ amount: accountBalance + accountTotalOutflowsAmount, relatedItem: item }); + item.inflows.push({ amount: accountBalance + accountTotalOutflowsAmount, relatedItem: incomeByAccountItem }); + } else if (accountNetCashFlow < 0) { // has negative net cash flow, add the difference to income account + incomeByAccountItem.totalAmount += -accountNetCashFlow; + incomeByAccountItem.totalNonNegativeAmount += -accountNetCashFlow > 0 ? -accountNetCashFlow : 0; + incomeByAccountItem.outflows.push({ amount: -accountNetCashFlow, relatedItem: item }); + item.inflows.push({ amount: -accountNetCashFlow, relatedItem: incomeByAccountItem }); + } + } + + if (accountNetCashFlow > 0) { + let netCashFlowItem: TransactionCategoricalOverviewAnalysisDataItem | undefined = allDataItemsMap[TransactionCategoricalOverviewAnalysisDataItemType.NetCashFlow]; + + if (!netCashFlowItem) { + netCashFlowItem = createNewTransactionCategoricalOverviewAnalysisDataItem( + TransactionCategoricalOverviewAnalysisDataItemType.NetCashFlow, + 'Net Cash Flow', + TransactionCategoricalOverviewAnalysisDataItemType.NetCashFlow, + [Number.MAX_SAFE_INTEGER], + false); + allDataItemsMap[TransactionCategoricalOverviewAnalysisDataItemType.NetCashFlow] = netCashFlowItem; + allNetCashFlowDataItems.push(netCashFlowItem); + } + + item.outflows.push({ amount: accountNetCashFlow, relatedItem: netCashFlowItem }); + + netCashFlowItem.totalAmount += accountNetCashFlow; + netCashFlowItem.totalNonNegativeAmount += accountNetCashFlow > 0 ? accountNetCashFlow : 0; + netCashFlowItem.inflows.push({ amount: accountNetCashFlow, relatedItem: item }); + } + } + + const allDataItems: TransactionCategoricalOverviewAnalysisDataItem[] = [ + ...allIncomeByPrimaryCategoryDataItems, + ...allIncomeBySecondaryCategoryDataItems, + ...allIncomeByAccountDataItems, + ...allOpeningBalanceDataItems, + ...allExpenseByAccountDataItems, + ...allExpenseBySecondaryCategoryDataItems, + ...allNetCashFlowDataItems, + ...allExpenseByPrimaryCategoryDataItems + ]; + return { - startTime: transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value.startTime, - endTime: transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value.endTime, + totalIncome: totalIncome, + totalExpense: totalExpense, items: allDataItems }; }); @@ -466,6 +709,42 @@ export const useStatisticsStore = defineStore('statistics', () => { return trendsData; }); + function createNewTransactionCategoricalOverviewAnalysisDataItem(id: string, name: string, type: TransactionCategoricalOverviewAnalysisDataItemType, displayOrders: number[], hidden: boolean): TransactionCategoricalOverviewAnalysisDataItem { + const dataItem: TransactionCategoricalOverviewAnalysisDataItem = { + id: id, + name: name, + type: type, + displayOrders: displayOrders, + hidden: hidden, + inflows: [], + outflows: [], + totalAmount: 0, + totalNonNegativeAmount: 0 + }; + + return dataItem; + } + + function sortCategoricalOverviewAnalysisDataItems(items: TransactionCategoricalOverviewAnalysisDataItem[], transactionStatisticsFilter: TransactionStatisticsFilter): void { + let totalNonNegativeAmount: number = 0; + + for (const item of items) { + totalNonNegativeAmount += item.totalNonNegativeAmount; + } + + if (totalNonNegativeAmount > 0) { + for (const item of items) { + if (!item.includeInPercent) { + continue; + } + + item.percent = item.totalAmount * 100 / totalNonNegativeAmount; + } + } + + sortStatisticsItems(items, transactionStatisticsFilter.sortingType); + } + function assembleAccountAndCategoryInfo(items: TransactionStatisticResponseItem[]): TransactionStatisticResponseItemWithInfo[] { const finalItems: TransactionStatisticResponseItemWithInfo[] = []; const defaultCurrency = userStore.currentUserDefaultCurrency; @@ -1289,7 +1568,7 @@ export const useStatisticsStore = defineStore('statistics', () => { transactionStatisticsStateInvalid, // computed states categoricalAnalysisChartDataCategory, - categoricalAllAnalysisData, + categoricalOverviewAnalysisData, categoricalAnalysisData, trendsAnalysisData, // functions diff --git a/src/views/base/statistics/StatisticsTransactionPageBase.ts b/src/views/base/statistics/StatisticsTransactionPageBase.ts index 4af9a029..297c6545 100644 --- a/src/views/base/statistics/StatisticsTransactionPageBase.ts +++ b/src/views/base/statistics/StatisticsTransactionPageBase.ts @@ -20,7 +20,7 @@ import { import { DISPLAY_HIDDEN_AMOUNT } from '@/consts/numeral.ts'; import type { - TransactionStatisticResponseWithInfo, + TransactionCategoricalOverviewAnalysisData, TransactionCategoricalAnalysisData, TransactionCategoricalAnalysisDataItem, TransactionTrendsAnalysisData @@ -249,8 +249,8 @@ export function useStatisticsTransactionPageBase() { query.value.chartDataType === ChartDataType.NetIncome.type; }); + const categoricalOverviewAnalysisData = computed(() => statisticsStore.categoricalOverviewAnalysisData); const categoricalAnalysisData = computed(() => statisticsStore.categoricalAnalysisData); - const categoricalAllAnalysisData = computed(() => statisticsStore.categoricalAllAnalysisData); const trendsAnalysisData = computed(() => statisticsStore.trendsAnalysisData); function canShowCustomDateRange(dateRangeType: number): boolean { @@ -323,8 +323,8 @@ export function useStatisticsTransactionPageBase() { showTotalAmountInTrendsChart, showStackedInTrendsChart, translateNameInTrendsChart, + categoricalOverviewAnalysisData, categoricalAnalysisData, - categoricalAllAnalysisData, trendsAnalysisData, // functions canShowCustomDateRange, diff --git a/src/views/desktop/statistics/TransactionPage.vue b/src/views/desktop/statistics/TransactionPage.vue index 3eeb1a0f..43e699b1 100644 --- a/src/views/desktop/statistics/TransactionPage.vue +++ b/src/views/desktop/statistics/TransactionPage.vue @@ -172,7 +172,27 @@ + v-if="queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && isQuerySpecialChartType && queryChartDataType === ChartDataType.Overview.type && (initing || categoricalOverviewAnalysisData && categoricalOverviewAnalysisData.items && categoricalOverviewAnalysisData.items.length)"> + {{ tt('Total Income') }} + + {{ getDisplayAmount(categoricalOverviewAnalysisData.totalIncome, defaultCurrency) }} + + + {{ tt('Total Expense') }} + + {{ getDisplayAmount(categoricalOverviewAnalysisData.totalExpense, defaultCurrency) }} + + + + + {{ totalAmountName }} + v-else-if="!initing && ( + (queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && isQuerySpecialChartType && queryChartDataType === ChartDataType.Overview.type && (!categoricalOverviewAnalysisData || !categoricalOverviewAnalysisData.items || !categoricalOverviewAnalysisData.items.length)) + || (queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && !isQuerySpecialChartType && (!categoricalAnalysisData || !categoricalAnalysisData.items || !categoricalAnalysisData.items.length)) + || (queryAnalysisType === StatisticsAnalysisType.TrendAnalysis && (!trendsAnalysisData || !trendsAnalysisData.items || !trendsAnalysisData.items.length)) + )"> {{ tt('No transaction data') }} @@ -198,8 +221,7 @@ v-if="initing" />