mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-17 16:24:25 +08:00
338 lines
11 KiB
Vue
338 lines
11 KiB
Vue
<template>
|
|
<v-card :class="{ 'disabled': disabled }">
|
|
<template #title>
|
|
<span>{{ tt('Income and Expense Trends') }}</span>
|
|
</template>
|
|
|
|
<v-card-text class="overview-monthly-chart-container overview-monthly-chart-overlay" v-if="loading && !hasAnyData">
|
|
<div class="overview-monthly-chart-skeleton-container h-100" style="margin-top: -30px">
|
|
<div class="d-flex w-100 h-100 align-center justify-center"
|
|
:key="itemIdx" v-for="itemIdx in [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ]">
|
|
<v-skeleton-loader width="16" height="200" :loading="true"></v-skeleton-loader>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<v-card-text class="overview-monthly-chart-container overview-monthly-chart-overlay" v-else-if="!loading && !hasAnyData">
|
|
<div class="d-flex flex-column align-center justify-center w-100 h-100">
|
|
<h2 style="margin-top: -40px">{{ tt('No data') }}</h2>
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<v-chart autoresize class="overview-monthly-chart-container"
|
|
:class="{ 'readonly': !hasAnyData }" :option="chartOptions" @click="clickItem"/>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue';
|
|
import type { ECElementEvent } from 'echarts/core';
|
|
import type { CallbackDataParams } from 'echarts/types/dist/shared';
|
|
|
|
import { useI18n } from '@/locales/helpers.ts';
|
|
|
|
import { useSettingsStore } from '@/stores/setting.ts';
|
|
import { useUserStore } from '@/stores/user.ts';
|
|
|
|
import { TransactionType } from '@/core/transaction.ts';
|
|
import { type TransactionMonthlyIncomeAndExpenseData } from '@/models/transaction.ts';
|
|
import {
|
|
parseDateFromUnixTime,
|
|
getMonthName
|
|
} from '@/lib/datetime.ts';
|
|
import { getExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
|
|
|
interface MonthlyIncomeAndExpenseCardClickEvent {
|
|
transactionType: TransactionType;
|
|
monthStartTime: number;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
loading: boolean;
|
|
data: TransactionMonthlyIncomeAndExpenseData[];
|
|
disabled: boolean;
|
|
isDarkMode?: boolean;
|
|
enableClickItem?: boolean;
|
|
}>();
|
|
const emit = defineEmits<{
|
|
(e: 'click', event: MonthlyIncomeAndExpenseCardClickEvent): void;
|
|
}>();
|
|
|
|
const { tt, getMonthShortName, formatAmountWithCurrency } = useI18n();
|
|
|
|
const settingsStore = useSettingsStore();
|
|
const userStore = useUserStore();
|
|
|
|
const showAmountInHomePage = computed<boolean>(() => settingsStore.appSettings.showAmountInHomePage);
|
|
const defaultCurrency = computed<string>(() => userStore.currentUserDefaultCurrency);
|
|
const hasAnyData = computed<boolean>(() => {
|
|
if (!props.data || !props.data.length || props.data.length < 1) {
|
|
return false;
|
|
}
|
|
|
|
for (let i = 0; i < props.data.length; i++) {
|
|
const item = props.data[i];
|
|
|
|
if (item.incomeAmount > 0 || item.incomeAmount < 0 || item.expenseAmount > 0 || item.expenseAmount < 0) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
const chartOptions = computed<object>(() => {
|
|
const monthNames: string[] = [];
|
|
const incomeAmounts: number[] = [];
|
|
const expenseAmounts: number[] = [];
|
|
let minAmount = 0;
|
|
let maxAmount = 0;
|
|
|
|
const expenseIncomeAmountColor = getExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor, props.isDarkMode);
|
|
|
|
if (props.data) {
|
|
for (let i = 0; i < props.data.length; i++) {
|
|
const item = props.data[i];
|
|
const month = getMonthName(parseDateFromUnixTime(item.monthStartTime));
|
|
|
|
monthNames.push(getMonthShortName(month));
|
|
incomeAmounts.push(item.incomeAmount);
|
|
expenseAmounts.push(-item.expenseAmount);
|
|
|
|
if (item.incomeAmount > maxAmount) {
|
|
maxAmount = item.incomeAmount;
|
|
}
|
|
|
|
if (-item.expenseAmount > maxAmount) {
|
|
maxAmount = -item.expenseAmount;
|
|
}
|
|
|
|
if (item.incomeAmount < minAmount) {
|
|
minAmount = item.incomeAmount;
|
|
}
|
|
|
|
if (-item.expenseAmount < minAmount) {
|
|
minAmount = -item.expenseAmount;
|
|
}
|
|
}
|
|
}
|
|
|
|
const amountGap = maxAmount - minAmount;
|
|
|
|
return {
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: {
|
|
type: 'shadow'
|
|
},
|
|
backgroundColor: props.isDarkMode ? '#333' : '#fff',
|
|
borderColor: props.isDarkMode ? '#333' : '#fff',
|
|
textStyle: {
|
|
color: props.isDarkMode ? '#eee' : '#333'
|
|
},
|
|
formatter: (params: CallbackDataParams[]) => {
|
|
let incomeAmount: string | null = null;
|
|
let expenseAmount: string | null = null;
|
|
|
|
for (let i = 0; i < params.length; i++) {
|
|
const param = params[i];
|
|
const dataIndex = param.dataIndex;
|
|
const data = props.data[dataIndex];
|
|
|
|
if (param.seriesId === 'seriesIncome') {
|
|
incomeAmount = getDisplayIncomeAmount(data);
|
|
} else if (param.seriesId === 'seriesExpense') {
|
|
expenseAmount = getDisplayExpenseAmount(data);
|
|
}
|
|
}
|
|
|
|
return `<table>` +
|
|
`<thead>` +
|
|
`<tr>` +
|
|
`<td colspan="2" class="text-left">${params[0].name}</td>` +
|
|
`</tr>` +
|
|
`</thead>` +
|
|
`<tbody>` +
|
|
(
|
|
incomeAmount !== null ?
|
|
`<tr>` +
|
|
`<td><span class="overview-monthly-chart-tooltip-indicator bg-income mr-1"></span><span class="mr-4">${tt('Income')}</span></td>` +
|
|
`<td><strong>${incomeAmount}</strong></td>` +
|
|
`</tr>` : ''
|
|
)+
|
|
(
|
|
expenseAmount !== null ?
|
|
`<tr>` +
|
|
`<td><span class="overview-monthly-chart-tooltip-indicator bg-expense mr-1"></span><span class="mr-4">${tt('Expense')}</span></td>` +
|
|
`<td><strong>${expenseAmount}</strong></td>` +
|
|
`</tr>` : ''
|
|
) +
|
|
`</tbody>` +
|
|
`</table>`;
|
|
}
|
|
},
|
|
legend: {
|
|
bottom: 20,
|
|
itemWidth: 14,
|
|
itemHeight: 14,
|
|
textStyle: {
|
|
color: props.isDarkMode ? '#eee' : '#333'
|
|
},
|
|
icon: 'circle',
|
|
data: [ tt('Income'), tt('Expense') ]
|
|
},
|
|
grid: {
|
|
left: '20px',
|
|
right: '20px',
|
|
top: '10px',
|
|
bottom: '100px'
|
|
},
|
|
xAxis: [
|
|
{
|
|
type: 'category',
|
|
data: monthNames,
|
|
axisLine: {
|
|
show: false
|
|
},
|
|
axisTick: {
|
|
show: false
|
|
},
|
|
axisLabel: {
|
|
padding: [ 20, 0, 0, 0 ]
|
|
}
|
|
}
|
|
],
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
min: minAmount - amountGap / 20,
|
|
max: maxAmount,
|
|
splitNumber: 10,
|
|
axisLabel: {
|
|
show: false
|
|
},
|
|
splitLine: {
|
|
show: false
|
|
}
|
|
},
|
|
{
|
|
type: 'value',
|
|
min: minAmount,
|
|
max: maxAmount + amountGap / 20,
|
|
splitNumber: 10,
|
|
axisLabel: {
|
|
show: false
|
|
},
|
|
splitLine: {
|
|
show: false
|
|
}
|
|
}
|
|
],
|
|
series: [
|
|
{
|
|
type: 'bar',
|
|
id: 'seriesIncome',
|
|
name: tt('Income'),
|
|
yAxisIndex: 0,
|
|
stack: 'Total',
|
|
itemStyle: {
|
|
color: expenseIncomeAmountColor.incomeAmountColor,
|
|
borderRadius: 16
|
|
},
|
|
emphasis: {
|
|
focus: 'series',
|
|
labelLine: {
|
|
show: false
|
|
}
|
|
},
|
|
barMaxWidth: 16,
|
|
data: incomeAmounts
|
|
},
|
|
{
|
|
type: 'bar',
|
|
id: 'seriesExpense',
|
|
name: tt('Expense'),
|
|
yAxisIndex: 1,
|
|
stack: 'Total',
|
|
itemStyle: {
|
|
color: expenseIncomeAmountColor.expenseAmountColor,
|
|
borderRadius: 16
|
|
},
|
|
emphasis: {
|
|
focus: 'series',
|
|
labelLine: {
|
|
show: false
|
|
}
|
|
},
|
|
barMaxWidth: 16,
|
|
data: expenseAmounts
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
function getDisplayCurrency(value: number | string, currencyCode: string): string {
|
|
return formatAmountWithCurrency(value, currencyCode) || '0';
|
|
}
|
|
|
|
function getDisplayAmount(amount: number, incomplete: boolean): string {
|
|
if (!showAmountInHomePage.value) {
|
|
return getDisplayCurrency('***', defaultCurrency.value);
|
|
}
|
|
|
|
return getDisplayCurrency(amount, defaultCurrency.value) + (incomplete ? '+' : '');
|
|
}
|
|
|
|
function getDisplayIncomeAmount(data: TransactionMonthlyIncomeAndExpenseData): string {
|
|
return getDisplayAmount(data.incomeAmount, data.incompleteIncomeAmount);
|
|
}
|
|
|
|
function getDisplayExpenseAmount(data: TransactionMonthlyIncomeAndExpenseData): string {
|
|
return getDisplayAmount(data.expenseAmount, data.incompleteExpenseAmount);
|
|
}
|
|
|
|
function clickItem(e: ECElementEvent): void {
|
|
if (!props.enableClickItem || !props.data || e.componentType !== 'series') {
|
|
return;
|
|
}
|
|
|
|
const clickData = props.data[e.dataIndex];
|
|
|
|
if (clickData && e.seriesId === 'seriesIncome') {
|
|
emit('click', {
|
|
transactionType: TransactionType.Income,
|
|
monthStartTime: clickData.monthStartTime
|
|
});
|
|
} else if (clickData && e.seriesId === 'seriesExpense') {
|
|
emit('click', {
|
|
transactionType: TransactionType.Expense,
|
|
monthStartTime: clickData.monthStartTime
|
|
});
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.overview-monthly-chart-container {
|
|
width: 100%;
|
|
height: 400px;
|
|
}
|
|
|
|
.overview-monthly-chart-overlay {
|
|
position: absolute !important;
|
|
z-index: 10;
|
|
}
|
|
|
|
.overview-monthly-chart-skeleton-container {
|
|
display: grid;
|
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
|
}
|
|
|
|
.overview-monthly-chart-tooltip-indicator {
|
|
display: inline-block;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 10px;
|
|
}
|
|
</style>
|