add overview sankey chart for categorical analysis on desktop version

This commit is contained in:
MaysWind
2025-10-27 03:37:29 +08:00
parent 5faf3bfe66
commit 5d1480cabc
25 changed files with 743 additions and 66 deletions
@@ -0,0 +1,520 @@
<template>
<v-chart autoresize class="account-category-sankey-chart-container" :class="{ 'transition-in': skeleton }" :option="chartOptions"
@click="clickItem" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useTheme } from 'vuetify';
import type { ECElementEvent } from 'echarts/core';
import type { CallbackDataParams } from 'echarts/types/dist/shared';
import { useI18n } from '@/locales/helpers.ts';
import { useUserStore } from '@/stores/user.ts';
import type {
SortableTransactionStatisticDataItem,
TransactionStatisticResponseItemWithInfo
} from '@/models/transaction.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 SankeyChartNodeItemType {
Account = 'account',
Category = 'category'
}
interface SankeyChartData {
nodes: SankeyChartNodeItem[];
links: SankeyChartLinkItem[];
}
interface SankeyChartNodeItem extends SortableTransactionStatisticDataItem {
itemType: SankeyChartNodeItemType;
itemId: string;
name: string;
nameId: string;
displayName: string;
displayOrders: number[];
totalAmount: number;
depth: number;
}
interface SankeyChartLinkItem {
sourceItemType: SankeyChartNodeItemType;
sourceItemId: string;
source: string;
sourceDisplayName: string;
targetItemType: SankeyChartNodeItemType;
targetItemId: string;
target: string;
targetDisplayName: string;
value: number;
}
const props = defineProps<{
skeleton?: boolean;
items: TransactionStatisticResponseItemWithInfo[];
sortingType: number;
defaultCurrency?: string;
enableClickItem?: boolean;
}>();
const emit = defineEmits<{
(e: 'click', sourceItemType: 'account' | 'category', sourceItemId: string, targetItemType?: 'account' | 'category', targetItemId?: string): void;
}>();
const theme = useTheme();
const { formatAmountToLocalizedNumeralsWithCurrency } = useI18n();
const userStore = useUserStore();
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const sankeyData = computed<SankeyChartData>(() => {
const primaryIncomeCategoryNodesMap: Record<string, SankeyChartNodeItem> = {};
const secondaryIncomeCategoryNodesMap: Record<string, SankeyChartNodeItem> = {};
const incomeAccountNodesMap: Record<string, SankeyChartNodeItem> = {};
const expenseAccountNodesMap: Record<string, SankeyChartNodeItem> = {};
const secondaryExpenseCategoryNodesMap: Record<string, SankeyChartNodeItem> = {};
const primaryExpenseCategoryNodesMap: Record<string, SankeyChartNodeItem> = {};
const linksMap: Record<string, SankeyChartLinkItem> = {};
for (const item of props.items) {
if (!item.primaryAccount || !item.account || !item.primaryCategory || !item.category || !item.amountInDefaultCurrency) {
continue;
}
if (item.account.hidden || item.primaryAccount.hidden || item.category.hidden || item.primaryCategory.hidden) {
continue;
}
if (item.relatedAccount && (item.relatedAccountType === TransactionRelatedAccountType.TransferFrom || item.relatedAccount.hidden || !item.relatedPrimaryAccount || item.relatedPrimaryAccount.hidden)) {
continue;
}
const incomeAccountNameId = `income_account:${item.account.id}`;
const expenseAccountNameId = `expense_account:${item.account.id}`;
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.primaryCategory.type == CategoryType.Transfer) ? item.amountInDefaultCurrency : 0,
depth: 2
});
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: 3
});
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: 0
});
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: 1
});
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: 4
});
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: 5
});
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}`;
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
});
}
}
for (const expenseAccountNode of values(expenseAccountNodesMap)) {
const incomeAccountNode = incomeAccountNodesMap[`income_account:${expenseAccountNode.itemId}`];
if (!incomeAccountNode) {
continue;
}
let totalOutflowAmount = 0;
let totalInflowAmount = 0;
for (const link of values(linksMap)) {
if (link.sourceItemType === SankeyChartNodeItemType.Account && link.sourceItemId === expenseAccountNode.itemId) {
totalOutflowAmount += link.value;
} else if (link.targetItemType === SankeyChartNodeItemType.Account && link.targetItemId === expenseAccountNode.itemId) {
totalInflowAmount += link.value;
}
}
const amountDifference = totalOutflowAmount - totalInflowAmount;
if (amountDifference > 0) {
updateLinkItem(linksMap, {
sourceItemType: SankeyChartNodeItemType.Account,
sourceItemId: incomeAccountNode.itemId,
source: incomeAccountNode.nameId,
sourceName: incomeAccountNode.displayName,
targetItemType: SankeyChartNodeItemType.Account,
targetItemId: expenseAccountNode.itemId,
target: expenseAccountNode.nameId,
targetName: expenseAccountNode.displayName,
value: amountDifference
});
}
}
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
};
return ret;
});
const chartOptions = computed<object>(() => {
const expenseIncomeAmountColor = getExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor, isDarkMode.value);
return {
tooltip: {
trigger: 'item',
backgroundColor: isDarkMode.value ? '#333' : '#fff',
borderColor: isDarkMode.value ? '#333' : '#fff',
textStyle: {
color: isDarkMode.value ? '#eee' : '#333'
},
formatter: (params: CallbackDataParams) => {
const value = isNumber(params.value) ? params.value as number : 0;
const displayValue = formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
if (params.dataType === 'node') {
const dataItem = params.data as SankeyChartNodeItem;
return `<div><span>${dataItem.displayName}</span><span class="ms-5" style="float: inline-end">${displayValue}</span></div>`;
} else if (params.dataType === 'edge') {
const dataItem = params.data as SankeyChartLinkItem;
return `<div><span>${dataItem.sourceDisplayName}${dataItem.targetDisplayName}</span><span class="ms-5" style="float: inline-end">${displayValue}</span></div>`;
} else {
return '';
}
}
},
series: [
{
type: 'sankey',
left: 10,
top: 10,
bottom: 10,
label: {
formatter: (params: CallbackDataParams) => {
const dataItem = params.data as SankeyChartNodeItem;
return dataItem.displayName;
}
},
levels: [
{
depth: 0,
itemStyle: {
color: expenseIncomeAmountColor.incomeAmountColor,
opacity: 0.6
},
lineStyle: {
color: 'source',
opacity: 0.3
}
},
{
depth: 1,
itemStyle: {
color: expenseIncomeAmountColor.incomeAmountColor,
opacity: 0.4
},
lineStyle: {
color: 'source',
opacity: 0.2
}
},
{
depth: 2,
itemStyle: {
color: '#c07d43',
opacity: 0.5
},
lineStyle: {
color: 'source',
opacity: 0.2
}
},
{
depth: 3,
itemStyle: {
color: '#c07d43',
opacity: 0.5
},
lineStyle: {
color: 'source',
opacity: 0.2
}
},
{
depth: 4,
itemStyle: {
color: expenseIncomeAmountColor.expenseAmountColor,
opacity: 0.4
},
lineStyle: {
color: 'source',
opacity: 0.2
}
},
{
depth: 5,
itemStyle: {
color: expenseIncomeAmountColor.expenseAmountColor,
opacity: 0.6
},
lineStyle: {
color: 'source',
opacity: 0.3
}
}
],
emphasis: {
focus: 'adjacency'
},
data: sankeyData.value.nodes,
links: sankeyData.value.links,
animation: !props.skeleton
}
]
};
});
function updateNodeItem(nodesMap: Record<string, SankeyChartNodeItem>, { 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<string, SankeyChartLinkItem>, { 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<string, SankeyChartNodeItem>, allNodesArray: SankeyChartNodeItem[]): void {
const nodesArray: SankeyChartNodeItem[] = [];
for (const node of values(nodesMap)) {
nodesArray.push(node);
}
sortStatisticsItems(nodesArray, props.sortingType);
for (const node of nodesArray) {
node.name = node.nameId;
}
allNodesArray.push(...nodesArray);
}
function addFinalLinkItems(linksMap: Record<string, SankeyChartLinkItem>, 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;
}
if (!e.data) {
return;
}
if (e.dataType === 'node') {
const dataItem = e.data as SankeyChartNodeItem;
emit('click', dataItem.itemType, dataItem.itemId);
} else if (e.dataType === 'edge') {
const dataItem = e.data as SankeyChartLinkItem;
if (dataItem.sourceItemType === dataItem.targetItemType && dataItem.sourceItemId === dataItem.targetItemId) {
emit('click', dataItem.sourceItemType, dataItem.sourceItemId);
} else {
emit('click', dataItem.sourceItemType, dataItem.sourceItemId, dataItem.targetItemType, dataItem.targetItemId);
}
}
}
</script>
<style scoped>
.account-category-sankey-chart-container {
width: 100%;
height: 460px;
}
@media (min-width: 600px) {
.account-category-sankey-chart-container {
height: 660px;
}
}
.account-category-sankey-chart-container.transition-in {
animation: radar-chart-skeleton-fade-in 2s 1;
}
@keyframes radar-chart-skeleton-fade-in {
0% {
opacity: 0;
}
20% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>
+1 -1
View File
@@ -261,7 +261,7 @@ function onLegendSelectChanged(e: { selected: Record<string, boolean> }): void {
@media (min-width: 600px) {
.pie-chart-container {
height: 560px;
height: 610px;
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ const chartOptions = computed<object>(() => {
@media (min-width: 600px) {
.radar-chart-container {
height: 560px;
height: 610px;
}
}
+34 -22
View File
@@ -89,35 +89,41 @@ export class AccountBalanceTrendChartType implements TypeAndName {
}
export class ChartDataType implements TypeAndName {
private static readonly allInstances: ChartDataType[] = [];
private static readonly allInstancesForAll: ChartDataType[] = [];
private static readonly allInstancesForDesktop: ChartDataType[] = [];
private static readonly allInstancesByType: Record<number, ChartDataType> = {};
public static readonly OutflowsByAccount = new ChartDataType(11, 'Outflows By Account', StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly ExpenseByAccount = new ChartDataType(0, 'Expense By Account', StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly ExpenseByPrimaryCategory = new ChartDataType(1, 'Expense By Primary Category', StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly ExpenseBySecondaryCategory = new ChartDataType(2, 'Expense By Secondary Category', StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly InflowsByAccount = new ChartDataType(12, 'Inflows By Account', StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly IncomeByAccount = new ChartDataType(3, 'Income By Account', StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly IncomeByPrimaryCategory = new ChartDataType(4, 'Income By Primary Category', StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly IncomeBySecondaryCategory = new ChartDataType(5, 'Income By Secondary Category', StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly AccountTotalAssets = new ChartDataType(6, 'Account Total Assets', StatisticsAnalysisType.CategoricalAnalysis);
public static readonly AccountTotalLiabilities = new ChartDataType(7, 'Account Total Liabilities', StatisticsAnalysisType.CategoricalAnalysis);
public static readonly TotalOutflows = new ChartDataType(13, 'Total Outflows', StatisticsAnalysisType.TrendAnalysis);
public static readonly TotalExpense = new ChartDataType(8, 'Total Expense', StatisticsAnalysisType.TrendAnalysis);
public static readonly TotalInflows = new ChartDataType(14, 'Total Inflows', StatisticsAnalysisType.TrendAnalysis);
public static readonly TotalIncome = new ChartDataType(9, 'Total Income', StatisticsAnalysisType.TrendAnalysis);
public static readonly NetCashFlow = new ChartDataType(15, 'Net Cash Flow', StatisticsAnalysisType.TrendAnalysis);
public static readonly NetIncome = new ChartDataType(10, 'Net Income', StatisticsAnalysisType.TrendAnalysis);
public static readonly Overview = new ChartDataType(16, 'Overview', true, true, StatisticsAnalysisType.CategoricalAnalysis);
public static readonly OutflowsByAccount = new ChartDataType(11, 'Outflows By Account', false, false, StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly ExpenseByAccount = new ChartDataType(0, 'Expense By Account', false, false, StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly ExpenseByPrimaryCategory = new ChartDataType(1, 'Expense By Primary Category', false, false, StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly ExpenseBySecondaryCategory = new ChartDataType(2, 'Expense By Secondary Category', false, false, StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly InflowsByAccount = new ChartDataType(12, 'Inflows By Account', false, false, StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly IncomeByAccount = new ChartDataType(3, 'Income By Account', false, false, StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly IncomeByPrimaryCategory = new ChartDataType(4, 'Income By Primary Category', false, false, StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly IncomeBySecondaryCategory = new ChartDataType(5, 'Income By Secondary Category', false, false, StatisticsAnalysisType.CategoricalAnalysis, StatisticsAnalysisType.TrendAnalysis);
public static readonly AccountTotalAssets = new ChartDataType(6, 'Account Total Assets', false, false, StatisticsAnalysisType.CategoricalAnalysis);
public static readonly AccountTotalLiabilities = new ChartDataType(7, 'Account Total Liabilities', false, false, StatisticsAnalysisType.CategoricalAnalysis);
public static readonly TotalOutflows = new ChartDataType(13, 'Total Outflows', false, false, StatisticsAnalysisType.TrendAnalysis);
public static readonly TotalExpense = new ChartDataType(8, 'Total Expense', false, false, StatisticsAnalysisType.TrendAnalysis);
public static readonly TotalInflows = new ChartDataType(14, 'Total Inflows', false, false, StatisticsAnalysisType.TrendAnalysis);
public static readonly TotalIncome = new ChartDataType(9, 'Total Income', false, false, StatisticsAnalysisType.TrendAnalysis);
public static readonly NetCashFlow = new ChartDataType(15, 'Net Cash Flow', false, false, StatisticsAnalysisType.TrendAnalysis);
public static readonly NetIncome = new ChartDataType(10, 'Net Income', false, false, StatisticsAnalysisType.TrendAnalysis);
public static readonly Default = ChartDataType.ExpenseByPrimaryCategory;
public readonly type: number;
public readonly name: string;
public readonly desktopOnly: boolean = false;
public readonly specialChart: boolean = false;
private readonly availableAnalysisTypes: Record<number, boolean>;
private constructor(type: number, name: string, ...availableAnalysisTypes: StatisticsAnalysisType[]) {
private constructor(type: number, name: string, desktopOnly: boolean, specialChart: boolean, ...availableAnalysisTypes: StatisticsAnalysisType[]) {
this.type = type;
this.name = name;
this.desktopOnly = desktopOnly;
this.specialChart = specialChart;
this.availableAnalysisTypes = {};
if (availableAnalysisTypes) {
@@ -126,7 +132,11 @@ export class ChartDataType implements TypeAndName {
}
}
ChartDataType.allInstances.push(this);
if (!desktopOnly) {
ChartDataType.allInstancesForAll.push(this);
}
ChartDataType.allInstancesForDesktop.push(this);
ChartDataType.allInstancesByType[type] = this;
}
@@ -134,14 +144,16 @@ export class ChartDataType implements TypeAndName {
return this.availableAnalysisTypes[analysisType] || false;
}
public static values(analysisType?: StatisticsAnalysisType): ChartDataType[] {
public static values(analysisType?: StatisticsAnalysisType, withDesktopOnlyChart?: boolean): ChartDataType[] {
const availableInstances: ChartDataType[] = withDesktopOnlyChart ? ChartDataType.allInstancesForDesktop : ChartDataType.allInstancesForAll;
if (analysisType === undefined) {
return ChartDataType.allInstances;
return availableInstances;
}
const ret: ChartDataType[] = [];
for (const chartDataType of ChartDataType.allInstances) {
for (const chartDataType of availableInstances) {
if (chartDataType.isAvailableAnalysisType(analysisType)) {
ret.push(chartDataType);
}
+4 -1
View File
@@ -52,7 +52,7 @@ import 'vuetify/styles';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { LineChart, BarChart, PieChart, CandlestickChart, RadarChart } from 'echarts/charts';
import { LineChart, BarChart, PieChart, CandlestickChart, RadarChart, SankeyChart } from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
@@ -105,6 +105,7 @@ import DateRangeSelectionDialog from '@/components/desktop/DateRangeSelectionDia
import MonthSelectionDialog from '@/components/desktop/MonthSelectionDialog.vue';
import MonthRangeSelectionDialog from '@/components/desktop/MonthRangeSelectionDialog.vue';
import AccountBalanceTrendsChart from '@/components/desktop/AccountBalanceTrendsChart.vue';
import AccountAndCategorySankeyChart from '@/components/desktop/AccountAndCategorySankeyChart.vue';
import SwitchToMobileDialog from '@/components/desktop/SwitchToMobileDialog.vue';
import '@/styles/desktop/template/vuetify/index.scss';
@@ -498,6 +499,7 @@ echarts.use([
PieChart,
CandlestickChart,
RadarChart,
SankeyChart,
GridComponent,
TooltipComponent,
LegendComponent
@@ -544,6 +546,7 @@ app.component('DateRangeSelectionDialog', DateRangeSelectionDialog);
app.component('MonthSelectionDialog', MonthSelectionDialog);
app.component('MonthRangeSelectionDialog', MonthRangeSelectionDialog);
app.component('AccountBalanceTrendsChart', AccountBalanceTrendsChart);
app.component('AccountAndCategorySankeyChart', AccountAndCategorySankeyChart);
app.component('SwitchToMobileDialog', SwitchToMobileDialog);
app.mount('#app');
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "Flächendiagramm",
"Column Chart": "Säulendiagramm",
"Candlestick Chart": "Candlestick Chart",
"Sankey Chart": "Sankey Chart",
"Sort by": "Sortieren nach",
"Map": "Karte",
"Provider": "Anbieter",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "Area Chart",
"Column Chart": "Column Chart",
"Candlestick Chart": "Candlestick Chart",
"Sankey Chart": "Sankey Chart",
"Sort by": "Sort by",
"Map": "Map",
"Provider": "Provider",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "Gráfico de área",
"Column Chart": "Gráfico de columnas",
"Candlestick Chart": "Candlestick Chart",
"Sankey Chart": "Sankey Chart",
"Sort by": "Ordenar por",
"Map": "Mapa",
"Provider": "Proveedor",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "Graphique en aires",
"Column Chart": "Graphique en colonnes",
"Candlestick Chart": "Graphique en chandelier",
"Sankey Chart": "Sankey Chart",
"Sort by": "Trier par",
"Map": "Carte",
"Provider": "Fournisseur",
+1 -1
View File
@@ -2352,7 +2352,7 @@ export function useI18n() {
getAllCategoricalChartTypes: (withDesktopOnlyChart?: boolean) => getLocalizedDisplayNameAndType(CategoricalChartType.values(!!withDesktopOnlyChart)),
getAllTrendChartTypes: () => getLocalizedDisplayNameAndType(TrendChartType.values()),
getAllAccountBalanceTrendChartTypes: () => getLocalizedDisplayNameAndType(AccountBalanceTrendChartType.values()),
getAllStatisticsChartDataTypes: (analysisType: StatisticsAnalysisType) => getLocalizedDisplayNameAndType(ChartDataType.values(analysisType)),
getAllStatisticsChartDataTypes: (analysisType: StatisticsAnalysisType, withDesktopOnlyChart?: boolean) => getLocalizedDisplayNameAndType(ChartDataType.values(analysisType, withDesktopOnlyChart)),
getAllStatisticsSortingTypes: () => getLocalizedDisplayNameAndType(ChartSortingType.values()),
getAllStatisticsDateAggregationTypes: () => getLocalizedChartDateAggregationTypeAndDisplayName(true),
getAllStatisticsDateAggregationTypesWithShortName: () => getLocalizedChartDateAggregationTypeAndDisplayName(false),
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "Grafico ad area",
"Column Chart": "Grafico a colonne",
"Candlestick Chart": "Candlestick Chart",
"Sankey Chart": "Sankey Chart",
"Sort by": "Ordina per",
"Map": "Mappa",
"Provider": "Fornitore",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "エリアチャート",
"Column Chart": "列チャート",
"Candlestick Chart": "Candlestick Chart",
"Sankey Chart": "Sankey Chart",
"Sort by": "ソート順",
"Map": "地図",
"Provider": "プロバイダー",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "영역 차트",
"Column Chart": "세로 막대 차트",
"Candlestick Chart": "캠들스틱 차트",
"Sankey Chart": "Sankey Chart",
"Sort by": "정렬 기준",
"Map": "지도",
"Provider": "제공자",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "Vlakdiagram",
"Column Chart": "Kolomdiagram",
"Candlestick Chart": "Candlestickdiagram",
"Sankey Chart": "Sankey Chart",
"Sort by": "Sorteren op",
"Map": "Kaart",
"Provider": "Provider",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "Gráfico de Área",
"Column Chart": "Gráfico de Colunas",
"Candlestick Chart": "Candlestick Chart",
"Sankey Chart": "Sankey Chart",
"Sort by": "Ordenar por",
"Map": "Mapa",
"Provider": "Provedor",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "Диаграмма с областями",
"Column Chart": "Столбчатая диаграмма",
"Candlestick Chart": "Candlestick Chart",
"Sankey Chart": "Sankey Chart",
"Sort by": "Сортировать по",
"Map": "Карта",
"Provider": "Провайдер",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "กราฟพื้นที่",
"Column Chart": "กราฟคอลัมน์",
"Candlestick Chart": "กราฟแท่งเทียน",
"Sankey Chart": "Sankey Chart",
"Sort by": "จัดเรียงตาม",
"Map": "แผนที่",
"Provider": "ผู้ให้บริการ",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "Діаграма з областями",
"Column Chart": "Стовпчикова діаграма",
"Candlestick Chart": "Candlestick Chart",
"Sankey Chart": "Sankey Chart",
"Sort by": "Сортувати за",
"Map": "Карта",
"Provider": "Провайдер",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "Biểu đồ diện tích",
"Column Chart": "Biểu đồ cột",
"Candlestick Chart": "Candlestick Chart",
"Sankey Chart": "Sankey Chart",
"Sort by": "Sắp xếp theo",
"Map": "Bản đồ",
"Provider": "Nhà cung cấp",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "面积图",
"Column Chart": "柱状图",
"Candlestick Chart": "K线图",
"Sankey Chart": "桑基图",
"Sort by": "排序方式",
"Map": "地图",
"Provider": "提供者",
+1
View File
@@ -1517,6 +1517,7 @@
"Area Chart": "面積圖",
"Column Chart": "柱狀圖",
"Candlestick Chart": "K線圖",
"Sankey Chart": "桑基圖",
"Sort by": "排序方式",
"Map": "地圖",
"Provider": "提供者",
+27
View File
@@ -722,6 +722,33 @@ export interface SortableTransactionStatisticDataItem {
readonly totalAmount: number;
}
export interface TransactionStatisticResponseItemWithInfo extends TransactionStatisticResponseItem {
categoryId: string;
accountId: string;
relatedAccountId?: string;
amount: number;
account?: Account;
primaryAccount?: Account;
relatedAccount?: Account;
relatedPrimaryAccount?: Account;
relatedAccountType?: number;
category?: TransactionCategory;
primaryCategory?: TransactionCategory;
amountInDefaultCurrency: number | null;
}
export interface TransactionStatisticResponseWithInfo {
readonly startTime: number;
readonly endTime: number;
readonly items: TransactionStatisticResponseItemWithInfo[];
}
export interface TransactionStatisticTrendsResponseItemWithInfo {
readonly year: number;
readonly month: number; // 1-based (1 = January, 12 = December)
readonly items: TransactionStatisticResponseItemWithInfo[];
}
export type TransactionStatisticDataItemType = 'category' | 'account' | 'total';
export interface TransactionStatisticDataItemBase extends SortableTransactionStatisticDataItem {
+76 -30
View File
@@ -28,12 +28,13 @@ 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 { Account } from '@/models/account.ts';
import type { TransactionCategory } from '@/models/transaction_category.ts';
import type {
TransactionStatisticResponse,
TransactionStatisticResponseItem,
TransactionStatisticTrendsResponseItem,
TransactionStatisticResponseItemWithInfo,
TransactionStatisticResponseWithInfo,
TransactionStatisticTrendsResponseItemWithInfo,
TransactionStatisticDataItemType,
TransactionStatisticDataItemBase,
TransactionCategoricalAnalysisData,
@@ -61,33 +62,6 @@ import { sortStatisticsItems } from '@/lib/statistics.ts';
import logger from '@/lib/logger.ts';
import services from '@/lib/services.ts';
interface TransactionStatisticResponseItemWithInfo extends TransactionStatisticResponseItem {
categoryId: string;
accountId: string;
relatedAccountId?: string;
amount: number;
account?: Account;
primaryAccount?: Account;
relatedAccount?: Account;
relatedPrimaryAccount?: Account;
relatedAccountType?: number;
category?: TransactionCategory;
primaryCategory?: TransactionCategory;
amountInDefaultCurrency: number | null;
}
interface TransactionStatisticResponseWithInfo {
readonly startTime: number;
readonly endTime: number;
readonly items: TransactionStatisticResponseItemWithInfo[];
}
interface TransactionStatisticTrendsResponseItemWithInfo {
readonly year: number;
readonly month: number; // 1-based (1 = January, 12 = December)
readonly items: TransactionStatisticResponseItemWithInfo[];
}
interface WritableTransactionCategoricalAnalysisData {
totalAmount: number;
totalNonNegativeAmount: number;
@@ -229,6 +203,36 @@ export const useStatisticsStore = defineStore('statistics', () => {
return getCategoryTotalAmountItems(transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value.items, transactionStatisticsFilter.value);
});
const categoricalAllAnalysisData = computed<TransactionStatisticResponseWithInfo | null>(() => {
if (!transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value || !transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value.items) {
return null;
}
const allDataItems: TransactionStatisticResponseItemWithInfo[] = [];
for (const item of transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value.items) {
if (!item.primaryAccount || !item.account || !item.primaryCategory || !item.category) {
continue;
}
if (transactionStatisticsFilter.value.filterAccountIds && transactionStatisticsFilter.value.filterAccountIds[item.account.id]) {
continue;
}
if (transactionStatisticsFilter.value.filterCategoryIds && transactionStatisticsFilter.value.filterCategoryIds[item.category.id]) {
continue;
}
allDataItems.push(item);
}
return {
startTime: transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value.startTime,
endTime: transactionCategoryStatisticsDataWithCategoryAndAccountInfo.value.endTime,
items: allDataItems
};
});
const accountTotalAmountAnalysisData = computed<WritableTransactionCategoricalAnalysisData | null>(() => {
if (!accountsStore.allPlainAccounts) {
return null;
@@ -1043,7 +1047,48 @@ export const useStatisticsStore = defineStore('statistics', () => {
querys.push('type=3');
}
if (itemId && (transactionStatisticsFilter.value.chartDataType === ChartDataType.InflowsByAccount.type
if (itemId && transactionStatisticsFilter.value.chartDataType === ChartDataType.Overview.type) {
const items = itemId.split('-');
const sourceItems = (items[0] || '').split(':');
const queryAccountIds: string[] = [];
const queryCategoryIds: string[] = [];
if (sourceItems.length === 2) {
if (sourceItems[0] === 'account') {
queryAccountIds.push(sourceItems[1] as string);
} else if (sourceItems[0] === 'category') {
queryCategoryIds.push(sourceItems[1] as string);
}
}
if (items.length === 2) {
const targetItems = (items[1] || '').split(':');
if (targetItems.length === 2) {
if (targetItems[0] === 'account') {
queryAccountIds.push(targetItems[1] as string);
} else if (targetItems[0] === 'category') {
queryCategoryIds.push(targetItems[1] as string);
}
}
}
if (queryAccountIds.length) {
if (queryAccountIds.length === 2) {
querys.push('type=4');
}
querys.push('accountIds=' + queryAccountIds.join(','));
} else {
querys.push('accountIds=' + getFinalAccountIdsByFilteredAccountIds(accountsStore.allAccountsMap, transactionStatisticsFilter.value.filterAccountIds));
}
if (queryCategoryIds.length) {
querys.push('categoryIds=' + queryCategoryIds.join(','));
} else {
querys.push('categoryIds=' + getFinalCategoryIdsByFilteredCategoryIds(transactionCategoriesStore.allTransactionCategoriesMap, transactionStatisticsFilter.value.filterCategoryIds));
}
} else if (itemId && (transactionStatisticsFilter.value.chartDataType === ChartDataType.InflowsByAccount.type
|| transactionStatisticsFilter.value.chartDataType === ChartDataType.IncomeByAccount.type
|| transactionStatisticsFilter.value.chartDataType === ChartDataType.OutflowsByAccount.type
|| transactionStatisticsFilter.value.chartDataType === ChartDataType.ExpenseByAccount.type
@@ -1197,6 +1242,7 @@ export const useStatisticsStore = defineStore('statistics', () => {
transactionStatisticsStateInvalid,
// computed states
categoricalAnalysisChartDataCategory,
categoricalAllAnalysisData,
categoricalAnalysisData,
trendsAnalysisData,
// functions
@@ -14,6 +14,7 @@ import { StatisticsAnalysisType, ChartDataType, ChartSortingType, ChartDateAggre
import { DISPLAY_HIDDEN_AMOUNT } from '@/consts/numeral.ts';
import type {
TransactionStatisticResponseWithInfo,
TransactionCategoricalAnalysisData,
TransactionCategoricalAnalysisDataItem,
TransactionTrendsAnalysisData
@@ -230,6 +231,7 @@ export function useStatisticsTransactionPageBase() {
});
const categoricalAnalysisData = computed<TransactionCategoricalAnalysisData>(() => statisticsStore.categoricalAnalysisData);
const categoricalAllAnalysisData = computed<TransactionStatisticResponseWithInfo | null>(() => statisticsStore.categoricalAllAnalysisData);
const trendsAnalysisData = computed<TransactionTrendsAnalysisData | null>(() => statisticsStore.trendsAnalysisData);
function canShowCustomDateRange(dateRangeType: number): boolean {
@@ -301,6 +303,7 @@ export function useStatisticsTransactionPageBase() {
showTotalAmountInTrendsChart,
translateNameInTrendsChart,
categoricalAnalysisData,
categoricalAllAnalysisData,
trendsAnalysisData,
// functions
canShowCustomDateRange,
@@ -21,6 +21,17 @@
:disabled="loading"
:items="allChartTypes"
v-model="queryChartType"
v-show="!isQuerySpecialChartType"
/>
<v-select
item-title="displayName"
item-value="type"
class="mt-2"
density="compact"
:disabled="true"
:items="[{ displayName: tt('Sankey Chart'), type: 0 }]"
:model-value="0"
v-show="isQuerySpecialChartType && queryChartDataType === ChartDataType.Overview.type"
/>
</div>
<div class="mx-6 mt-4">
@@ -38,7 +49,7 @@
<v-tabs show-arrows class="my-4" direction="vertical"
:disabled="loading" v-model="queryChartDataType">
<v-tab class="tab-text-truncate" :key="dataType.type" :value="dataType.type"
v-for="dataType in ChartDataType.values()"
v-for="dataType in ChartDataType.values(undefined, true)"
v-show="dataType.isAvailableAnalysisType(queryAnalysisType)">
<span class="text-truncate">{{ tt(dataType.name) }}</span>
<v-tooltip activator="parent" location="right">{{ tt(dataType.name) }}</v-tooltip>
@@ -48,7 +59,7 @@
<v-main>
<v-window class="d-flex flex-grow-1 disable-tab-transition w-100-window-container" v-model="activeTab">
<v-window-item value="statisticsPage">
<v-card variant="flat" :min-height="queryAnalysisType === StatisticsAnalysisType.TrendAnalysis ? '860' : '700'">
<v-card variant="flat" :min-height="queryAnalysisType === StatisticsAnalysisType.TrendAnalysis ? '860' : '760'">
<template #title>
<div class="title-and-toolbar d-flex align-center">
<v-btn class="me-3 d-md-none" density="compact" color="default" variant="plain"
@@ -160,7 +171,7 @@
</template>
<v-card-text class="statistics-overview-title pt-0" :class="{ 'disabled': loading }"
v-if="queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && (initing || (categoricalAnalysisData && categoricalAnalysisData.items && categoricalAnalysisData.items.length))">
v-if="queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && !isQuerySpecialChartType && (initing || (categoricalAnalysisData && categoricalAnalysisData.items && categoricalAnalysisData.items.length))">
<span class="statistics-subtitle">{{ totalAmountName }}</span>
<span class="statistics-overview-amount ms-3"
:class="statisticsTextColor"
@@ -173,12 +184,29 @@
</v-card-text>
<v-card-text class="statistics-overview-title pt-0"
v-else-if="!initing && ((queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && (!categoricalAnalysisData || !categoricalAnalysisData.items || !categoricalAnalysisData.items.length))
v-else-if="!initing && ((queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && !isQuerySpecialChartType && (!categoricalAnalysisData || !categoricalAnalysisData.items || !categoricalAnalysisData.items.length))
|| (queryAnalysisType === StatisticsAnalysisType.TrendAnalysis && (!trendsAnalysisData || !trendsAnalysisData.items || !trendsAnalysisData.items.length)))">
<span class="statistics-subtitle statistics-overview-empty-tip">{{ tt('No transaction data') }}</span>
</v-card-text>
<v-card-text :class="{ 'readonly': loading }" v-if="queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && query.categoricalChartType === CategoricalChartType.Pie.type">
<v-card-text :class="{ 'readonly': loading }" v-if="queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && queryChartDataType === ChartDataType.Overview.type">
<account-and-category-sankey-chart
:items="[]"
:sorting-type="querySortingType"
:skeleton="true"
v-if="initing"
/>
<account-and-category-sankey-chart
:items="categoricalAllAnalysisData && categoricalAllAnalysisData.items && categoricalAllAnalysisData.items.length ? categoricalAllAnalysisData.items : []"
:sorting-type="querySortingType"
:enable-click-item="true"
:default-currency="defaultCurrency"
v-else-if="!initing"
@click="onClickSankeyChartItem"
/>
</v-card-text>
<v-card-text :class="{ 'readonly': loading }" v-if="queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && !isQuerySpecialChartType && query.categoricalChartType === CategoricalChartType.Pie.type">
<pie-chart
:items="[
{id: '1', name: '---', value: 60, color: '7c7c7f'},
@@ -191,7 +219,7 @@
value-field="value"
color-field="color"
v-if="initing"
></pie-chart>
/>
<pie-chart
:items="categoricalAnalysisData && categoricalAnalysisData.items && categoricalAnalysisData.items.length ? categoricalAnalysisData.items : []"
:min-valid-percent="0.0001"
@@ -208,7 +236,7 @@
/>
</v-card-text>
<v-card-text :class="{ 'readonly': loading }" v-if="queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && query.categoricalChartType === CategoricalChartType.Bar.type">
<v-card-text :class="{ 'readonly': loading }" v-if="queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && !isQuerySpecialChartType && query.categoricalChartType === CategoricalChartType.Bar.type">
<v-list rounded lines="two" v-if="initing">
<template :key="itemIdx" v-for="itemIdx in [ 1, 2, 3 ]">
<v-list-item class="ps-0">
@@ -263,7 +291,7 @@
</v-list>
</v-card-text>
<v-card-text :class="{ 'readonly': loading }" v-if="queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && query.categoricalChartType === CategoricalChartType.Radar.type">
<v-card-text :class="{ 'readonly': loading }" v-if="queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && !isQuerySpecialChartType && query.categoricalChartType === CategoricalChartType.Radar.type">
<radar-chart
:items="[
{name: '---', value: 10},
@@ -277,7 +305,7 @@
name-field="name"
value-field="value"
v-if="initing"
></radar-chart>
/>
<radar-chart
:items="categoricalAnalysisData && categoricalAnalysisData.items && categoricalAnalysisData.items.length ? categoricalAnalysisData.items : []"
:min-valid-percent="0.0001"
@@ -493,6 +521,7 @@ const {
showTotalAmountInTrendsChart,
translateNameInTrendsChart,
categoricalAnalysisData,
categoricalAllAnalysisData,
trendsAnalysisData,
canShowCustomDateRange,
getTransactionCategoricalAnalysisDataItemDisplayColor,
@@ -576,6 +605,10 @@ const querySortingType = computed<number>({
}
});
const isQuerySpecialChartType = computed<boolean>(() => {
return ChartDataType.valueOf(queryChartDataType.value)?.specialChart ?? false;
});
const statisticsTextColor = computed<string>(() => {
if (query.value.chartDataType === ChartDataType.OutflowsByAccount.type ||
query.value.chartDataType === ChartDataType.ExpenseByAccount.type ||
@@ -705,7 +738,8 @@ function reload(force: boolean): Promise<unknown> | null {
loading.value = true;
if (query.value.chartDataType === ChartDataType.OutflowsByAccount.type ||
if (query.value.chartDataType === ChartDataType.Overview.type ||
query.value.chartDataType === ChartDataType.OutflowsByAccount.type ||
query.value.chartDataType === ChartDataType.ExpenseByAccount.type ||
query.value.chartDataType === ChartDataType.ExpenseByPrimaryCategory.type ||
query.value.chartDataType === ChartDataType.ExpenseBySecondaryCategory.type ||
@@ -1019,6 +1053,23 @@ function exportResults(): void {
}
}
function onClickSankeyChartItem(sourceItemType: 'account' | 'category', sourceItemId: string, targetItemType?: 'account' | 'category', targetItemId?: string): void {
if (sourceItemType === 'category' && targetItemType === 'category' && sourceItemId && targetItemId) {
const sourceCategory = transactionCategoriesStore.allTransactionCategoriesMap[sourceItemId];
const targetCategory = transactionCategoriesStore.allTransactionCategoriesMap[targetItemId];
if (sourceCategory?.parentId === targetCategory?.id) {
router.push(getTransactionItemLinkUrl(`${sourceItemType}:${sourceItemId}`));
return;
} else if (targetCategory?.parentId === sourceCategory?.id) {
router.push(getTransactionItemLinkUrl(`${targetItemType}:${targetItemId}`));
return;
}
}
router.push(getTransactionItemLinkUrl(`${sourceItemType}:${sourceItemId}` + (targetItemType && targetItemId ? `-${targetItemType}:${targetItemId}` : '')));
}
function onClickPieChartItem(item: Record<string, unknown>): void {
router.push(getTransactionItemLinkUrl(item['id'] as string));
}