add overview sankey chart for categorical analysis on desktop version
This commit is contained in:
@@ -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>
|
||||
@@ -261,7 +261,7 @@ function onLegendSelectChanged(e: { selected: Record<string, boolean> }): void {
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.pie-chart-container {
|
||||
height: 560px;
|
||||
height: 610px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ const chartOptions = computed<object>(() => {
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.radar-chart-container {
|
||||
height: 560px;
|
||||
height: 610px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-22
@@ -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
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1517,6 +1517,7 @@
|
||||
"Area Chart": "エリアチャート",
|
||||
"Column Chart": "列チャート",
|
||||
"Candlestick Chart": "Candlestick Chart",
|
||||
"Sankey Chart": "Sankey Chart",
|
||||
"Sort by": "ソート順",
|
||||
"Map": "地図",
|
||||
"Provider": "プロバイダー",
|
||||
|
||||
@@ -1517,6 +1517,7 @@
|
||||
"Area Chart": "영역 차트",
|
||||
"Column Chart": "세로 막대 차트",
|
||||
"Candlestick Chart": "캠들스틱 차트",
|
||||
"Sankey Chart": "Sankey Chart",
|
||||
"Sort by": "정렬 기준",
|
||||
"Map": "지도",
|
||||
"Provider": "제공자",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1517,6 +1517,7 @@
|
||||
"Area Chart": "Диаграмма с областями",
|
||||
"Column Chart": "Столбчатая диаграмма",
|
||||
"Candlestick Chart": "Candlestick Chart",
|
||||
"Sankey Chart": "Sankey Chart",
|
||||
"Sort by": "Сортировать по",
|
||||
"Map": "Карта",
|
||||
"Provider": "Провайдер",
|
||||
|
||||
@@ -1517,6 +1517,7 @@
|
||||
"Area Chart": "กราฟพื้นที่",
|
||||
"Column Chart": "กราฟคอลัมน์",
|
||||
"Candlestick Chart": "กราฟแท่งเทียน",
|
||||
"Sankey Chart": "Sankey Chart",
|
||||
"Sort by": "จัดเรียงตาม",
|
||||
"Map": "แผนที่",
|
||||
"Provider": "ผู้ให้บริการ",
|
||||
|
||||
@@ -1517,6 +1517,7 @@
|
||||
"Area Chart": "Діаграма з областями",
|
||||
"Column Chart": "Стовпчикова діаграма",
|
||||
"Candlestick Chart": "Candlestick Chart",
|
||||
"Sankey Chart": "Sankey Chart",
|
||||
"Sort by": "Сортувати за",
|
||||
"Map": "Карта",
|
||||
"Provider": "Провайдер",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1517,6 +1517,7 @@
|
||||
"Area Chart": "面积图",
|
||||
"Column Chart": "柱状图",
|
||||
"Candlestick Chart": "K线图",
|
||||
"Sankey Chart": "桑基图",
|
||||
"Sort by": "排序方式",
|
||||
"Map": "地图",
|
||||
"Provider": "提供者",
|
||||
|
||||
@@ -1517,6 +1517,7 @@
|
||||
"Area Chart": "面積圖",
|
||||
"Column Chart": "柱狀圖",
|
||||
"Candlestick Chart": "K線圖",
|
||||
"Sankey Chart": "桑基圖",
|
||||
"Sort by": "排序方式",
|
||||
"Map": "地圖",
|
||||
"Provider": "提供者",
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user