add trend in income and expense card in overview page

This commit is contained in:
MaysWind
2023-07-30 23:00:00 +08:00
parent 6cb7e4caf7
commit dea36d4b80
13 changed files with 462 additions and 20 deletions
+1 -1
View File
@@ -290,7 +290,7 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.Context) (interface{
return nil, errs.ErrQueryItemsEmpty
}
if len(requestItems) > 5 {
if len(requestItems) > 10 {
log.WarnfWithRequestId(c, "[transactions.TransactionAmountsHandler] parse request failed, because there are too many items")
return nil, errs.ErrQueryItemsTooMuch
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="256px" height="256px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>

After

Width:  |  Height:  |  Size: 2.5 KiB

+16
View File
@@ -1,3 +1,18 @@
const allMonthsArray = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
const allWeekDays = {
Sunday: {
type: 0,
@@ -190,6 +205,7 @@ const defaultDateTimeFormatValue = 0;
export default {
allWeekDays: allWeekDays,
allWeekDaysArray: allWeekDaysArray,
allMonthsArray: allMonthsArray,
allLongDateFormat: allLongDateFormat,
allLongDateFormatArray: allLongDateFormatArray,
allShortDateFormat: allShortDateFormat,
+4 -1
View File
@@ -46,8 +46,9 @@ import 'vuetify/styles';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts';
import { BarChart, PieChart } from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
LegendComponent,
} from 'echarts/components';
@@ -363,7 +364,9 @@ const vuetify = createVuetify({
echarts.use([
CanvasRenderer,
BarChart,
PieChart,
GridComponent,
TooltipComponent,
LegendComponent
]);
+5
View File
@@ -133,6 +133,11 @@ export function getDayOfWeekName(date) {
return dateTimeConstants.allWeekDaysArray[dayOfWeek].name;
}
export function getMonthName(date) {
const dayOfWeek = moment(date).month();
return dateTimeConstants.allMonthsArray[dayOfWeek];
}
export function getHour(date) {
return moment(date).hour();
}
+21 -1
View File
@@ -258,7 +258,7 @@ export default {
return axios.get('v1/transactions/statistics.json' + (queryParams.length ? '?' + queryParams.join('&') : ''));
},
getTransactionAmounts: ({ today, thisWeek, thisMonth, thisYear }) => {
getTransactionAmounts: ({ today, thisWeek, thisMonth, thisYear, lastMonth, monthBeforeLastMonth, monthBeforeLast2Months, monthBeforeLast3Months, monthBeforeLast4Months }) => {
const queryParams = [];
if (today) {
@@ -277,6 +277,26 @@ export default {
queryParams.push(`thisYear_${thisYear.startTime}_${thisYear.endTime}`);
}
if (lastMonth) {
queryParams.push(`lastMonth_${lastMonth.startTime}_${lastMonth.endTime}`);
}
if (monthBeforeLastMonth) {
queryParams.push(`monthBeforeLastMonth_${monthBeforeLastMonth.startTime}_${monthBeforeLastMonth.endTime}`);
}
if (monthBeforeLast2Months) {
queryParams.push(`monthBeforeLast2Months_${monthBeforeLast2Months.startTime}_${monthBeforeLast2Months.endTime}`);
}
if (monthBeforeLast3Months) {
queryParams.push(`monthBeforeLast3Months_${monthBeforeLast3Months.startTime}_${monthBeforeLast3Months.endTime}`);
}
if (monthBeforeLast4Months) {
queryParams.push(`monthBeforeLast4Months_${monthBeforeLast4Months.startTime}_${monthBeforeLast4Months.endTime}`);
}
return axios.get('v1/transactions/amounts.json' + (queryParams.length ? '?query=' + queryParams.join('|') : ''));
},
getTransaction: ({ id }) => {
+1
View File
@@ -837,6 +837,7 @@ export default {
'PIN code is wrong': 'PIN code is wrong',
'Sign Up': 'Sign Up',
'Overview': 'Overview',
'Trend in Income and Expense': 'Trend in Income and Expense',
'View Details': 'View Details',
'Transaction List': 'Transaction List',
'Account List': 'Account List',
+1
View File
@@ -837,6 +837,7 @@ export default {
'PIN code is wrong': 'PIN码错误',
'Sign Up': '注册',
'Overview': '总览',
'Trend in Income and Expense': '收入与支出趋势',
'View Details': '查看详情',
'Transaction List': '交易列表',
'Account List': '账户列表',
+65 -9
View File
@@ -5,6 +5,7 @@ import { useExchangeRatesStore } from './exchangeRates.js';
import { isNumber, isEquals } from '@/lib/common.js';
import {
getUnixTimeBeforeUnixTime,
getTodayFirstUnixTime,
getTodayLastUnixTime,
getThisWeekFirstUnixTime,
@@ -31,6 +32,21 @@ function updateTransactionDateRange(state) {
state.transactionDataRange.thisYear.startTime = getThisYearFirstUnixTime();
state.transactionDataRange.thisYear.endTime = getThisYearLastUnixTime();
state.transactionDataRange.lastMonth.startTime = getUnixTimeBeforeUnixTime(getThisMonthFirstUnixTime(), 1, 'months');
state.transactionDataRange.lastMonth.endTime = getUnixTimeBeforeUnixTime(getThisMonthLastUnixTime(), 1, 'months');
state.transactionDataRange.monthBeforeLastMonth.startTime = getUnixTimeBeforeUnixTime(getThisMonthFirstUnixTime(), 2, 'months');
state.transactionDataRange.monthBeforeLastMonth.endTime = getUnixTimeBeforeUnixTime(getThisMonthLastUnixTime(), 2, 'months');
state.transactionDataRange.monthBeforeLast2Months.startTime = getUnixTimeBeforeUnixTime(getThisMonthFirstUnixTime(), 3, 'months');
state.transactionDataRange.monthBeforeLast2Months.endTime = getUnixTimeBeforeUnixTime(getThisMonthLastUnixTime(), 3, 'months');
state.transactionDataRange.monthBeforeLast3Months.startTime = getUnixTimeBeforeUnixTime(getThisMonthFirstUnixTime(), 4, 'months');
state.transactionDataRange.monthBeforeLast3Months.endTime = getUnixTimeBeforeUnixTime(getThisMonthLastUnixTime(), 4, 'months');
state.transactionDataRange.monthBeforeLast4Months.startTime = getUnixTimeBeforeUnixTime(getThisMonthFirstUnixTime(), 5, 'months');
state.transactionDataRange.monthBeforeLast4Months.endTime = getUnixTimeBeforeUnixTime(getThisMonthLastUnixTime(), 5, 'months');
}
export const useOverviewStore = defineStore('overview', {
@@ -51,8 +67,31 @@ export const useOverviewStore = defineStore('overview', {
thisYear: {
startTime: getThisYearFirstUnixTime(),
endTime: getThisYearLastUnixTime()
},
lastMonth: {
startTime: getUnixTimeBeforeUnixTime(getThisMonthFirstUnixTime(), 1, 'months'),
endTime: getUnixTimeBeforeUnixTime(getThisMonthLastUnixTime(), 1, 'months')
},
monthBeforeLastMonth: {
startTime: getUnixTimeBeforeUnixTime(getThisMonthFirstUnixTime(), 2, 'months'),
endTime: getUnixTimeBeforeUnixTime(getThisMonthLastUnixTime(), 2, 'months')
},
monthBeforeLast2Months: {
startTime: getUnixTimeBeforeUnixTime(getThisMonthFirstUnixTime(), 3, 'months'),
endTime: getUnixTimeBeforeUnixTime(getThisMonthLastUnixTime(), 3, 'months')
},
monthBeforeLast3Months: {
startTime: getUnixTimeBeforeUnixTime(getThisMonthFirstUnixTime(), 4, 'months'),
endTime: getUnixTimeBeforeUnixTime(getThisMonthLastUnixTime(), 4, 'months')
},
monthBeforeLast4Months: {
startTime: getUnixTimeBeforeUnixTime(getThisMonthFirstUnixTime(), 5, 'months'),
endTime: getUnixTimeBeforeUnixTime(getThisMonthLastUnixTime(), 5, 'months')
}
},
transactionOverviewOptions: {
loadLast5Months: false
},
transactionOverviewData: {},
transactionOverviewStateInvalid: true
}),
@@ -78,7 +117,7 @@ export const useOverviewStore = defineStore('overview', {
const finalOverviewData = {};
const defaultCurrency = userStore.currentUserDefaultCurrency;
[ 'today', 'thisWeek', 'thisMonth', 'thisYear' ].forEach(field => {
[ 'today', 'thisWeek', 'thisMonth', 'thisYear', 'lastMonth', 'monthBeforeLastMonth', 'monthBeforeLast2Months', 'monthBeforeLast3Months', 'monthBeforeLast4Months' ].forEach(field => {
if (!Object.prototype.hasOwnProperty.call(overviewData, field)) {
return;
}
@@ -139,31 +178,47 @@ export const useOverviewStore = defineStore('overview', {
},
resetTransactionOverview() {
updateTransactionDateRange(this);
this.transactionOverviewOptions.loadLast5Months = false;
this.transactionOverviewData = {};
this.transactionOverviewStateInvalid = true;
},
loadTransactionOverview({ force }) {
loadTransactionOverview({ force, loadLast5Months }) {
const self = this;
let dateChanged = false;
let rangeChanged = false;
if (self.transactionDataRange.today.startTime !== getTodayFirstUnixTime()) {
dateChanged = true;
updateTransactionDateRange(self);
}
if (!dateChanged && !force && !self.transactionOverviewStateInvalid) {
if (loadLast5Months && !self.transactionOverviewOptions.loadLast5Months) {
rangeChanged = true;
}
if (!dateChanged && !rangeChanged && !force && !self.transactionOverviewStateInvalid) {
return new Promise((resolve) => {
resolve(self.transactionOverviewData);
});
}
const requestParams = {
today: self.transactionDataRange.today,
thisWeek: self.transactionDataRange.thisWeek,
thisMonth: self.transactionDataRange.thisMonth,
thisYear: self.transactionDataRange.thisYear
};
if (loadLast5Months) {
requestParams.lastMonth = self.transactionDataRange.lastMonth;
requestParams.monthBeforeLastMonth = self.transactionDataRange.monthBeforeLastMonth;
requestParams.monthBeforeLast2Months = self.transactionDataRange.monthBeforeLast2Months;
requestParams.monthBeforeLast3Months = self.transactionDataRange.monthBeforeLast3Months;
requestParams.monthBeforeLast4Months = self.transactionDataRange.monthBeforeLast4Months;
}
return new Promise((resolve, reject) => {
services.getTransactionAmounts({
today: self.transactionDataRange.today,
thisWeek: self.transactionDataRange.thisWeek,
thisMonth: self.transactionDataRange.thisMonth,
thisYear: self.transactionDataRange.thisYear
}).then(response => {
services.getTransactionAmounts(requestParams).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
@@ -181,6 +236,7 @@ export const useOverviewStore = defineStore('overview', {
}
self.transactionOverviewData = data.result;
self.transactionOverviewOptions.loadLast5Months = loadLast5Months;
resolve(data.result);
}).catch(error => {
+78 -8
View File
@@ -4,9 +4,11 @@
<v-card :class="{ 'disabled': loadingOverview }">
<template #title>
<div class="d-flex align-center">
<span class="text-2xl font-weight-bold">{{ displayDateRange.thisMonth.displayTime }}</span>
<span>·</span>
<small>{{ $t('Expense') }}</small>
<div class="d-flex align-baseline">
<span class="text-2xl font-weight-bold">{{ displayDateRange.thisMonth.displayTime }}</span>
<span>·</span>
<small>{{ $t('Expense') }}</small>
</div>
<v-btn density="compact" color="default" variant="text"
class="ml-2" :icon="true"
v-if="!loadingOverview" @click="reload(true)">
@@ -25,11 +27,13 @@
<v-icon :icon="showAmountInHomePage ? icons.eyeSlash : icons.eye" size="20" />
</v-btn>
</h5>
<p>
<div class="mt-2 mb-3">
<span class="mr-2">{{ $t('Monthly income') }}</span>
<span>{{ transactionOverview && transactionOverview.thisMonth ? getDisplayIncomeAmount(transactionOverview.thisMonth) : '-' }}</span>
</p>
<v-btn size="small" to="/transactions">{{ $t('View Details') }}</v-btn>
</div>
<v-btn size="small" to="/transactions?dateType=7">{{ $t('View Details') }}</v-btn>
<v-img class="overview-card-background" src="img/desktop/card-background.png"/>
<v-img class="overview-card-background-image" width="116px" src="img/desktop/document.svg"/>
</v-card-text>
</v-card>
</v-col>
@@ -97,13 +101,21 @@
</template>
</income-expense-overview-card>
</v-col>
<v-col cols="12" md="6">
<monthly-income-and-expense-card :data="monthlyIncomeAndExpenseData"
:disabled="loadingOverview" :is-dark-mode="isDarkMode" />
</v-col>
</v-row>
<snack-bar ref="snackbar" />
</template>
<script>
import { useTheme } from 'vuetify';
import IncomeExpenseOverviewCard from './overview/IncomeExpenseOverviewCard.vue';
import MonthlyIncomeAndExpenseCard from './overview/MonthlyIncomeAndExpenseCard.vue';
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.js';
@@ -117,6 +129,7 @@ import {
mdiRefresh,
mdiEyeOutline,
mdiEyeOffOutline,
mdiCartOutline,
mdiCalendarTodayOutline,
mdiCalendarWeekOutline,
mdiCalendarMonthOutline,
@@ -127,7 +140,8 @@ import {
export default {
components: {
IncomeExpenseOverviewCard
IncomeExpenseOverviewCard,
MonthlyIncomeAndExpenseCard
},
data() {
return {
@@ -136,6 +150,7 @@ export default {
refresh: mdiRefresh,
eye: mdiEyeOutline,
eyeSlash: mdiEyeOffOutline,
cart: mdiCartOutline,
calendarToday: mdiCalendarTodayOutline,
calendarWeek: mdiCalendarWeekOutline,
calendarMonth: mdiCalendarMonthOutline,
@@ -147,6 +162,9 @@ export default {
},
computed: {
...mapStores(useSettingsStore, useUserStore, useOverviewStore),
isDarkMode() {
return this.globalTheme.global.name.value === 'dark';
},
showAmountInHomePage: {
get: function() {
return this.settingsStore.appSettings.showAmountInHomePage;
@@ -184,6 +202,34 @@ export default {
},
transactionOverview() {
return this.overviewStore.transactionOverview;
},
monthlyIncomeAndExpenseData() {
const self = this;
const data = [];
[ 'monthBeforeLast4Months', 'monthBeforeLast3Months', 'monthBeforeLast2Months', 'monthBeforeLastMonth', 'lastMonth', 'thisMonth' ].forEach(fieldName => {
if (!Object.prototype.hasOwnProperty.call(self.transactionOverview, fieldName)) {
return;
}
const dateRange = self.overviewStore.transactionDataRange[fieldName];
if (!dateRange) {
return;
}
const item = self.overviewStore.transactionOverview[fieldName];
data.push({
monthStartTime: dateRange.startTime,
incomeAmount: item ? item.incomeAmount : 0,
expenseAmount: item ? item.expenseAmount : 0,
incompleteIncomeAmount: item ? item.incompleteIncomeAmount : true,
incompleteExpenseAmount: item ? item.incompleteExpenseAmount : true
});
});
return data;
}
},
created() {
@@ -191,6 +237,13 @@ export default {
this.reload(false);
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
methods: {
reload(force) {
const self = this;
@@ -198,7 +251,8 @@ export default {
self.loadingOverview = true;
self.overviewStore.loadTransactionOverview({
force: force
force: force,
loadLast5Months: true
}).then(() => {
self.loadingOverview = false;
@@ -235,3 +289,19 @@ export default {
}
}
</script>
<style>
.overview-card-background {
position: absolute;
inline-size: 9rem;
inset-block-end: 0;
inset-inline-end: 0;
}
.overview-card-background-image {
position: absolute;
inline-size: 5rem;
inset-block-end: 0.5rem;
inset-inline-end: 1rem;
}
</style>
@@ -0,0 +1,258 @@
<template>
<v-card :class="{ 'disabled': disabled }">
<template #title>
<span class="text-2xl font-weight-bold">{{ $t('Trend in Income and Expense') }}</span>
</template>
<v-chart class="overview-monthly-chart" autoresize :option="chartOptions" />
</v-card>
</template>
<script>
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
import {
parseDateFromUnixTime,
getMonthName
} from '@/lib/datetime.js';
export default {
props: [
'data',
'disabled',
'isDarkMode'
],
computed: {
...mapStores(useSettingsStore, useUserStore),
showAmountInHomePage() {
return this.settingsStore.appSettings.showAmountInHomePage;
},
defaultCurrency() {
return this.userStore.currentUserDefaultCurrency;
},
chartOptions() {
const self = this;
const monthNames = [];
const incomeAmounts = [];
const expenseAmounts = [];
let minAmount = 0;
let maxAmount = 0;
if (self.data) {
for (let i = 0; i < self.data.length; i++) {
const item = self.data[i];
const month = getMonthName(parseDateFromUnixTime(item.monthStartTime));
monthNames.push(self.$locale.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;
}
}
}
let amountGap = maxAmount - minAmount;
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
backgroundColor: self.isDarkMode ? '#333' : '#fff',
borderColor: self.isDarkMode ? '#333' : '#fff',
textStyle: {
color: self.isDarkMode ? '#eee' : '#333'
},
formatter: params => {
let incomeAmount = 0;
let incomeColor = '#cc4a66';
let expenseAmount = 0;
let expenseColor = '#4dd291';
for (let i = 0; i < params.length; i++) {
const param = params[i];
const dataIndex = param.dataIndex;
const data = self.data[dataIndex];
if (param.seriesId === 'seriesIncome') {
incomeAmount = self.getDisplayIncomeAmount(data);
incomeColor = param.color;
} else if (param.seriesId === 'seriesExpense') {
expenseAmount = self.getDisplayExpenseAmount(data);
expenseColor = param.color;
}
}
return `<table>` +
`<thead>` +
`<tr>` +
`<td colspan="2" class="text-left">${params[0].name}</td>` +
`</tr>` +
`</thead>` +
`<tbody>` +
`<tr>` +
`<td><span class="overview-monthly-chart-tooltip-indicator mr-1" style="background-color: ${incomeColor}"></span><span class="mr-4">${self.$t('Income')}</span></td>` +
`<td><strong>${incomeAmount}</strong></td>` +
`</tr>` +
`<tr>` +
`<td><span class="overview-monthly-chart-tooltip-indicator mr-1" style="background-color: ${expenseColor}"></span><span class="mr-4">${self.$t('Expense')}</span></td>` +
`<td><strong>${expenseAmount}</strong></td>` +
`</tr>` +
`</tbody>` +
`</table>`;
}
},
legend: {
bottom: 20,
itemWidth: 14,
itemHeight: 14,
textStyle: {
color: self.isDarkMode ? '#eee' : '#333'
},
icon: 'circle',
data: [ self.$t('Income'), self.$t('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: self.$t('Income'),
yAxisIndex: 0,
stack: 'Total',
itemStyle: {
color: '#cc4a66',
borderRadius: 16
},
emphasis: {
focus: 'series',
labelLine: {
show: false
}
},
barMaxWidth: 40,
data: incomeAmounts
},
{
type: 'bar',
id: 'seriesExpense',
name: self.$t('Expense'),
yAxisIndex: 1,
stack: 'Total',
itemStyle: {
color: '#4dd291',
borderRadius: 16
},
emphasis: {
focus: 'series',
labelLine: {
show: false
}
},
barMaxWidth: 40,
data: expenseAmounts
}
]
};
}
},
methods: {
getDisplayCurrency(value, currencyCode) {
return this.$locale.getDisplayCurrency(value, currencyCode, {
currencyDisplayMode: this.settingsStore.appSettings.currencyDisplayMode,
enableThousandsSeparator: this.settingsStore.appSettings.thousandsSeparator
});
},
getDisplayAmount(amount, incomplete) {
if (!this.showAmountInHomePage) {
return this.getDisplayCurrency('***', this.defaultCurrency);
}
return this.getDisplayCurrency(amount, this.defaultCurrency) + (incomplete ? '+' : '');
},
getDisplayIncomeAmount(category) {
return this.getDisplayAmount(category.incomeAmount, category.incompleteIncomeAmount);
},
getDisplayExpenseAmount(category) {
return this.getDisplayAmount(category.expenseAmount, category.incompleteExpenseAmount);
}
}
}
</script>
<style>
.overview-monthly-chart {
width: 100%;
height: 400px;
}
.overview-monthly-chart-tooltip-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 10px;
}
</style>
+5
View File
@@ -270,6 +270,11 @@
"url": "https://materialdesignicons.com",
"licenseUrl": "https://github.com/Templarian/MaterialDesign-JS/blob/v7.2.96/LICENSE"
},
{
"name": "Solar Icons Set",
"url": "https://www.figma.com/community/file/1166831539721848736/Solar-Icons-Set",
"licenseUrl": "https://creativecommons.org/licenses/by/4.0"
},
{
"name": "Hand drawn minimal background",
"url": "https://www.freepik.com/free-vector/hand-drawn-minimal-background_15441932.htm",