add transaction statistics page

This commit is contained in:
MaysWind
2023-07-09 23:58:33 +08:00
parent 298c0922cb
commit 5e986b2d04
10 changed files with 1289 additions and 4 deletions
+74
View File
@@ -16,6 +16,7 @@
"clipboard": "^2.0.11",
"crypto-js": "^4.1.1",
"dom7": "^4.0.6",
"echarts": "^5.4.2",
"framework7": "^8.1.0",
"framework7-icons": "^5.0.5",
"framework7-vue": "^8.1.0",
@@ -30,6 +31,7 @@
"swiper": "^9.3.2",
"ua-parser-js": "^1.0.35",
"vue": "^3.3.4",
"vue-echarts": "^6.6.0",
"vue-i18n": "^9.2.2",
"vue-router": "^4.2.2",
"vue3-perfect-scrollbar": "^1.6.1",
@@ -4177,6 +4179,15 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/echarts": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.4.2.tgz",
"integrity": "sha512-2W3vw3oI2tWJdyAz+b8DuWS0nfXtSDqlDmqgin/lfzbkB01cuMEN66KWBlmur3YMp5nEDEEt5s23pllnAzB4EA==",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.4.3"
}
},
"node_modules/ejs": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz",
@@ -7376,6 +7387,11 @@
"node": ">=0.10.0"
}
},
"node_modules/resize-detector": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/resize-detector/-/resize-detector-0.3.0.tgz",
"integrity": "sha512-R/tCuvuOHQ8o2boRP6vgx8hXCCy87H1eY9V5imBYeVNyNVpuL9ciReSccLj2gDcax9+2weXy3bc8Vv+NRXeEvQ=="
},
"node_modules/resolve": {
"version": "1.22.2",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz",
@@ -7942,6 +7958,11 @@
"punycode": "^2.1.0"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -8244,6 +8265,51 @@
"@vue/shared": "3.3.4"
}
},
"node_modules/vue-echarts": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-6.6.0.tgz",
"integrity": "sha512-PpXDe1wKKzOJTLM8RM4GJxpRi+9eiC0YUJR7i44/AAa0oDatvNctJcZlfq/bUV4KV+HDsCeQzaB6JocrXEy9LA==",
"hasInstallScript": true,
"dependencies": {
"resize-detector": "^0.3.0",
"vue-demi": "^0.13.2"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.5",
"echarts": "^5.4.1",
"vue": "^2.6.12 || ^3.1.1"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-echarts/node_modules/vue-demi": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
"integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-eslint-parser": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.3.0.tgz",
@@ -8811,6 +8877,14 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zrender": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.4.3.tgz",
"integrity": "sha512-DRUM4ZLnoaT0PBVvGBDO9oWIDBKFdAVieNWxWwK0niYzJCMwGchRk21/hsE+RKkIveH3XHCyvXcJDkgLVvfizQ==",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}
+2
View File
@@ -25,6 +25,7 @@
"clipboard": "^2.0.11",
"crypto-js": "^4.1.1",
"dom7": "^4.0.6",
"echarts": "^5.4.2",
"framework7": "^8.1.0",
"framework7-icons": "^5.0.5",
"framework7-vue": "^8.1.0",
@@ -39,6 +40,7 @@
"swiper": "^9.3.2",
"ua-parser-js": "^1.0.35",
"vue": "^3.3.4",
"vue-echarts": "^6.6.0",
"vue-i18n": "^9.2.2",
"vue-router": "^4.2.2",
"vue3-perfect-scrollbar": "^1.6.1",
@@ -0,0 +1,194 @@
<template>
<v-dialog width="460" v-model="showState">
<v-card>
<v-toolbar color="primary">
<v-toolbar-title>{{ title }}</v-toolbar-title>
</v-toolbar>
<v-card-text class="pa-4">
<p v-if="hint">{{ hint }}</p>
<span v-if="beginDateTime && endDateTime">
<span>{{ beginDateTime }}</span>
<span> - </span>
<span>{{ endDateTime }}</span>
</span>
<slot></slot>
</v-card-text>
<v-card-text class="pa-4 w-100 d-flex justify-center">
<vue-date-picker range inline enable-seconds auto-apply
ref="datetimepicker"
month-name-format="long"
six-weeks="center"
:clearable="false"
:dark="isDarkMode"
:week-start="firstDayOfWeek"
:year-range="yearRange"
:day-names="dayNames"
:is24="is24Hour"
:partial-range="false"
:preset-ranges="presetRanges"
v-model="dateRange">
<template #month="{ text }">
{{ $t(`datetime.${text}.short`) }}
</template>
<template #month-overlay-value="{ text }">
{{ $t(`datetime.${text}.short`) }}
</template>
</vue-date-picker>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="gray" @click="cancel">{{ $t('Cancel') }}</v-btn>
<v-btn :disabled="!dateRange[0] || !dateRange[1]" @click="confirm">{{ $t('OK') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import { useTheme } from 'vuetify';
import { mapStores } from 'pinia';
import { useUserStore } from '@/stores/user.js';
import datetimeConstants from '@/consts/datetime.js';
import { arrangeArrayWithNewStartIndex } from '@/lib/common.js';
import {
getCurrentUnixTime,
getCurrentDateTime,
getUnixTime,
getLocalDatetimeFromUnixTime,
getTodayFirstUnixTime,
getYear,
getDummyUnixTimeForLocalUsage,
getActualUnixTimeForStore,
getTimezoneOffsetMinutes,
getBrowserTimezoneOffsetMinutes,
getDateRangeByDateType
} from '@/lib/datetime.js';
export default {
props: [
'minTime',
'maxTime',
'title',
'hint',
'show'
],
emits: [
'update:show',
'dateRange:change'
],
data() {
const self = this;
let minDate = getTodayFirstUnixTime();
let maxDate = getCurrentUnixTime();
if (self.minTime) {
minDate = self.minTime;
}
if (self.maxTime) {
maxDate = self.maxTime;
}
return {
yearRange: [
2000,
getYear(getCurrentDateTime()) + 1
],
dateRange: [
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(minDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(maxDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
]
}
},
computed: {
...mapStores(useUserStore),
showState: {
get: function () {
return this.show;
},
set: function (value) {
this.$emit('update:show', value);
}
},
isDarkMode() {
return this.globalTheme.global.name.value === 'dark';
},
firstDayOfWeek() {
return this.userStore.currentUserFirstDayOfWeek;
},
dayNames() {
return arrangeArrayWithNewStartIndex(this.$locale.getAllMinWeekdayNames(), this.firstDayOfWeek);
},
is24Hour() {
return this.$locale.isLongTime24HourFormat(this.userStore);
},
beginDateTime() {
const actualBeginUnixTime = getActualUnixTimeForStore(getUnixTime(this.dateRange[0]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, actualBeginUnixTime);
},
endDateTime() {
const actualEndUnixTime = getActualUnixTimeForStore(getUnixTime(this.dateRange[1]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, actualEndUnixTime);
},
presetRanges() {
const presetRanges = [];
[
datetimeConstants.allDateRanges.Today,
datetimeConstants.allDateRanges.LastSevenDays,
datetimeConstants.allDateRanges.LastThirtyDays,
datetimeConstants.allDateRanges.ThisWeek,
datetimeConstants.allDateRanges.ThisMonth,
datetimeConstants.allDateRanges.ThisYear
].forEach(dateRangeType => {
const dateRange = getDateRangeByDateType(dateRangeType.type, this.firstDayOfWeek);
presetRanges.push({
label: this.$t(dateRangeType.name),
range: [
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.minTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.maxTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
]
});
});
return presetRanges;
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
methods: {
confirm() {
if (!this.dateRange[0] || !this.dateRange[1]) {
return;
}
const currentMinDate = this.dateRange[0];
const currentMaxDate = this.dateRange[1];
let minUnixTime = getUnixTime(currentMinDate);
let maxUnixTime = getUnixTime(currentMaxDate);
if (minUnixTime < 0 || maxUnixTime < 0) {
this.$toast('Date is too early');
return;
}
minUnixTime = getActualUnixTimeForStore(minUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
maxUnixTime = getActualUnixTimeForStore(maxUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
this.$emit('dateRange:change', minUnixTime, maxUnixTime);
},
cancel() {
this.$emit('update:show', false);
}
}
}
</script>
+13
View File
@@ -27,6 +27,7 @@ export default {
'color',
'defaultColor',
'additionalColorAttr',
'size',
'hiddenStatus'
],
data() {
@@ -102,6 +103,10 @@ export default {
ret[additionalColorAttr] = color;
}
if (this.size) {
ret['font-size'] = this.size;
}
return ret;
},
getCategoryIconStyle(color, defaultColor, additionalColorAttr) {
@@ -119,6 +124,10 @@ export default {
ret[additionalColorAttr] = color;
}
if (this.size) {
ret['font-size'] = this.size;
}
return ret;
},
getDefaultIconStyle(color, defaultColor, additionalColorAttr) {
@@ -136,6 +145,10 @@ export default {
ret[additionalColorAttr] = color;
}
if (this.size) {
ret['font-size'] = this.size;
}
return ret;
}
}
+265
View File
@@ -0,0 +1,265 @@
<template>
<v-chart class="pie-chart-container" autoresize :option="chartOptions"
@click="clickItem" @legendselectchanged="onLegendSelectChanged" />
</template>
<script>
import { useTheme } from 'vuetify';
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.js';
import colorConstants from '@/consts/color.js';
import statisticsConstants from '@/consts/statistics.js';
import { formatPercent } from '@/lib/common.js';
export default {
props: [
'items',
'idField',
'nameField',
'valueField',
'percentField',
'currencyField',
'colorField',
'hiddenField',
'minValidPercent',
'defaultCurrency',
'showValue',
'enableClickItem'
],
emits: [
'click'
],
data() {
return {
selectedLegends: null
};
},
computed: {
...mapStores(useSettingsStore),
isDarkMode() {
return this.globalTheme.global.name.value === 'dark';
},
itemsMap: function () {
const map = {};
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
let id = '';
if (this.idField && item[this.idField]) {
id = item[this.idField];
} else {
id = item[this.nameField];
}
map[id] = item;
}
return map;
},
validItems: function () {
let totalValidValue = 0;
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
if (item[this.valueField] && item[this.valueField] > 0 && (!this.hiddenField || !item[this.hiddenField])) {
totalValidValue += item[this.valueField];
}
}
const validItems = [];
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
if (item[this.valueField] && item[this.valueField] > 0 &&
(!this.hiddenField || !item[this.hiddenField]) &&
(!this.minValidPercent || item[this.valueField] / totalValidValue > this.minValidPercent)) {
const finalItem = {
id: (this.idField && item[this.idField]) ? item[this.idField] : item[this.nameField],
name: (this.idField && item[this.idField]) ? item[this.idField] : item[this.nameField],
displayName: item[this.nameField],
value: item[this.valueField],
percent: (item[this.percentField] > 0 || item[this.percentField] === 0 || item[this.percentField] === '0') ? item[this.percentField] : (item[this.valueField] / totalValidValue * 100),
actualPercent: item[this.valueField] / totalValidValue,
currency: item[this.currencyField],
itemStyle: {
color: this.getColor(item[this.colorField] ? item[this.colorField] : statisticsConstants.defaultChartColors[validItems.length % statisticsConstants.defaultChartColors.length]),
},
selected: true,
sourceItem: item
};
finalItem.displayPercent = formatPercent(finalItem.percent, 2, '&lt;0.01');
finalItem.displayValue = this.getDisplayCurrency(finalItem.value, (finalItem.currency || this.defaultCurrency));
validItems.push(finalItem);
}
}
return validItems;
},
hasUnselectedItem: function () {
for (let i = 0; i < this.validItems.length; i++) {
const item = this.validItems[i];
if (this.selectedLegends && !this.selectedLegends[item.id]) {
return true;
}
}
return false;
},
currentFirstItemPercent: function () {
let totalValue = 0;
let firstValue = null;
for (let i = 0; i < this.validItems.length; i++) {
const item = this.validItems[i];
if (this.selectedLegends && !this.selectedLegends[item.id]) {
continue;
}
if (firstValue === null) {
firstValue = item.value;
}
totalValue += item.value;
}
if (firstValue && totalValue > 0) {
return firstValue / totalValue;
} else {
return 0;
}
},
chartOptions: function () {
const self = this;
return {
tooltip: {
trigger: 'item',
backgroundColor: self.isDarkMode ? '#333' : '#fff',
textStyle: {
color: self.isDarkMode ? '#eee' : '#333'
},
formatter: params => {
const name = params.data ? params.data.displayName : '';
const value = params.data ? params.data.displayValue : self.getDisplayCurrency(params.value);
let percent = params.data ? params.data.displayPercent : (params.percent + '%');
if (self.hasUnselectedItem) {
percent = params.percent + '%';
}
if (name) {
return `${name}<br/>${value} (${percent})`;
} else {
return `${value} (${percent})`;
}
}
},
legend: {
orient: 'horizontal',
left: 'top',
data: self.validItems.map(item => item.name),
selected: self.selectedLegends,
textStyle: {
color: self.isDarkMode ? '#eee' : '#333'
},
formatter: id => {
return self.itemsMap[id] && self.nameField && self.itemsMap[id][self.nameField] ? self.itemsMap[id][self.nameField] : id;
}
},
series: [
{
type: 'pie',
data: self.validItems,
top: 50,
startAngle: -90 + self.currentFirstItemPercent / 2 * 360,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
}
},
label: {
color: self.isDarkMode ? '#eee' : '#333',
formatter: params => {
return params.data ? params.data.displayName : '';
}
}
}
],
media: [
{
query: {
minWidth: 600
},
option: {
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
type: 'pie',
top: 0
}
]
}
}
]
}
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
methods: {
clickItem: function (e) {
if (this.enableClickItem && e.componentType === 'series' && e.seriesType ==='pie' && e.data && e.data.sourceItem) {
this.$emit('click', e.data.sourceItem);
}
},
onLegendSelectChanged: function (e) {
this.selectedLegends = e.selected;
},
getColor: function (color) {
if (color && color !== colorConstants.defaultColor) {
color = '#' + color;
}
return color;
},
getDisplayCurrency(value, currencyCode) {
return this.$locale.getDisplayCurrency(value, currencyCode, {
currencyDisplayMode: this.settingsStore.appSettings.currencyDisplayMode,
enableThousandsSeparator: this.settingsStore.appSettings.thousandsSeparator
});
}
}
}
</script>
<style scoped>
.pie-chart-container {
width: 100%;
height: 400px;
}
@media (min-width: 600px) {
.pie-chart-container {
height: 500px;
}
}
</style>
+25
View File
@@ -8,6 +8,8 @@ import { VApp } from 'vuetify/components/VApp';
import { VAvatar } from 'vuetify/components/VAvatar';
import { VAutocomplete } from 'vuetify/components/VAutocomplete';
import { VBtn } from 'vuetify/components/VBtn';
import { VBtnGroup } from 'vuetify/components/VBtnGroup';
import { VBtnToggle } from 'vuetify/components/VBtnToggle';
import { VCard, VCardActions, VCardItem, VCardSubtitle, VCardText, VCardTitle } from 'vuetify/components/VCard';
import { VChip } from 'vuetify/components/VChip';
import { VDialog } from 'vuetify/components/VDialog';
@@ -41,6 +43,15 @@ import { VWindow, VWindowItem } from 'vuetify/components/VWindow';
import { aliases, mdi } from 'vuetify/iconsets/mdi-svg';
import 'vuetify/styles';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts';
import {
TooltipComponent,
LegendComponent,
} from 'echarts/components';
import VChart from 'vue-echarts';
import 'line-awesome/dist/line-awesome/css/line-awesome.css';
import { PerfectScrollbar } from 'vue3-perfect-scrollbar';
@@ -66,6 +77,8 @@ import AmountInput from '@/components/desktop/AmountInput.vue';
import StepsBar from '@/components/desktop/StepsBar.vue';
import ConfirmDialog from '@/components/desktop/ConfirmDialog.vue';
import SnackBar from '@/components/desktop/SnackBar.vue';
import PieChartComponent from '@/components/desktop/PieChart.vue';
import DateRangeSelectionDialog from '@/components/desktop/DateRangeSelectionDialog.vue';
import SwitchToMobileDialog from '@/components/desktop/SwitchToMobileDialog.vue';
import '@/styles/desktop/template/base/libs/vuetify/_index.scss';
@@ -88,6 +101,8 @@ const vuetify = createVuetify({
VAvatar,
VAutocomplete,
VBtn,
VBtnGroup,
VBtnToggle,
VCard,
VCardActions,
VCardItem,
@@ -338,11 +353,19 @@ const vuetify = createVuetify({
}
});
echarts.use([
CanvasRenderer,
PieChart,
TooltipComponent,
LegendComponent
]);
app.use(pinia);
app.use(i18n);
app.use(vuetify);
app.use(router);
app.component('VChart', VChart);
app.component('PerfectScrollbar', PerfectScrollbar);
app.component('VueDatePicker', VueDatePicker);
@@ -353,6 +376,8 @@ app.component('AmountInput', AmountInput);
app.component('StepsBar', StepsBar);
app.component('ConfirmDialog', ConfirmDialog);
app.component('SnackBar', SnackBar);
app.component('PieChart', PieChartComponent);
app.component('DateRangeSelectionDialog', DateRangeSelectionDialog);
app.component('SwitchToMobileDialog', SwitchToMobileDialog);
app.config.globalProperties.$version = getVersion();
+53
View File
@@ -29,6 +29,14 @@ input[type=number] {
}
/** custom class **/
:root {
--default-icon-color: var(--v-theme-on-surface);
}
:root .dark {
--default-icon-color: var(--v-theme-on-surface);
}
.pin-codes-input {
--ebk-pin-code-input-height: 56px;
--ebk-pin-code-input-gap: 12px;
@@ -83,3 +91,48 @@ input[type=number] {
min-width: 16px;
height: 1rem;
}
/** Replacing the default style of @vuepic/vue-datepicker **/
.dp__theme_light {
--dp-primary-color: #c67e48;
}
.dp__theme_dark {
--dp-primary-color: #c67e48;
}
/** Fix @vuepic/vue-datepicker style issue **/
.dp__main.dp__flex_display {
flex-direction: column
}
.dp__main .dp__preset_range {
white-space: inherit;
}
.dp__main .dp__menu_inner {
padding-top: 0;
padding-bottom: 0;
}
.dp__main .dp__menu_inner .dp__month_year_row > button {
width: inherit;
}
.dp__main .dp__menu_inner .dp__month_year_row > button.dp__button {
width: 100%;
}
.dp__main .dp__menu_inner .dp__month_year_row .dp__month_year_wrap > button {
line-height: inherit;
}
.dp__main .dp__calendar .dp__calendar_item {
display: flex;
justify-content: center;
flex: 1;
}
.dp__main .dp__calendar .dp__calendar_item > .dp__cell_inner {
width: 100%;
}
@@ -1,13 +1,660 @@
<template>
<v-row class="match-height">
statistics
<v-col cols="12">
<v-card>
<div class="d-flex flex-column flex-md-row">
<div>
<v-tabs show-arrows direction="vertical"
class="text-uppercase my-4" v-model="query.chartDataType">
<v-tab :key="dataType.type" :value="dataType.type"
v-for="dataType in allChartDataTypes">
{{ $t(dataType.name) }}
</v-tab>
</v-tabs>
</div>
<v-window class="d-flex flex-grow-1 ml-md-5 disable-tab-transition statistics-container" v-model="activeTab">
<v-window-item value="statisticsPage">
<v-card variant="flat">
<template #title>
<div class="d-flex align-center">
<div class="statistics-toolbar">
<v-btn-toggle
variant="outlined"
color="primary"
density="comfortable"
mandatory="force"
divided
:disabled="loading"
v-model="query.chartType"
>
<v-btn :value="allChartTypes.Pie" @click="setChartType(allChartTypes.Pie)">
{{ $t('Pie Chart') }}
</v-btn>
<v-btn :value="allChartTypes.Bar" @click="setChartType(allChartTypes.Bar)">
{{ $t('Bar Chart') }}
</v-btn>
</v-btn-toggle>
<v-btn-group class="ml-3" color="default"
density="comfortable" variant="outlined"
divided>
<v-btn :icon="icons.left"
:disabled="loading || query.dateType === allDateRanges.All.type || query.chartDataType === allChartDataTypes.AccountTotalAssets.type || query.chartDataType === allChartDataTypes.AccountTotalLiabilities.type"
@click="shiftDateRange(query.startTime, query.endTime, -1)"/>
<v-menu location="bottom">
<template #activator="{ props }">
<v-btn :disabled="loading || query.chartDataType === allChartDataTypes.AccountTotalAssets.type || query.chartDataType === allChartDataTypes.AccountTotalLiabilities.type"
v-bind="props">{{ dateRangeName(query) }}</v-btn>
</template>
<v-list>
<v-list-item :key="dateRange.type" :value="dateRange.type"
:append-icon="(query.dateType === dateRange.type ? icons.check : null)"
v-for="dateRange in allDateRanges">
<v-list-item-title
class="cursor-pointer"
@click="setDateFilter(dateRange.type)">
{{ $t(dateRange.name) }}
<div class="text-body-2" v-if="dateRange.type === allDateRanges.Custom.type && query.dateType === allDateRanges.Custom.type && query.startTime && query.endTime">
<small>
<span>{{ queryStartTime }}</span>
<span>&nbsp;-&nbsp;</span>
<br/>
<span>{{ queryEndTime }}</span>
</small>
</div>
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn :icon="icons.right"
:disabled="loading || query.dateType === allDateRanges.All.type || query.chartDataType === allChartDataTypes.AccountTotalAssets.type || query.chartDataType === allChartDataTypes.AccountTotalLiabilities.type"
@click="shiftDateRange(query.startTime, query.endTime, 1)"/>
</v-btn-group>
<v-menu location="bottom">
<template #activator="{ props }">
<v-btn class="ml-3" color="default" variant="outlined"
:prepend-icon="icons.sort" :disabled="loading"
v-bind="props">{{ querySortingTypeName }}</v-btn>
</template>
<v-list>
<v-list-item :key="sortingType.type" :value="sortingType.type"
:append-icon="(query.sortingType === sortingType.type ? icons.check : null)"
v-for="sortingType in allSortingTypes">
<v-list-item-title
class="cursor-pointer"
@click="setSortingType(sortingType.type)">
{{ $t(sortingType.fullName) }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<v-btn density="compact" color="default" variant="text"
class="ml-2" :icon="true" :disabled="loading"
v-if="!loading" @click="reload">
<v-icon :icon="icons.refresh" size="24" />
<v-tooltip activator="parent">{{ $t('Refresh') }}</v-tooltip>
</v-btn>
<v-progress-circular indeterminate size="24" class="ml-2" v-if="loading"></v-progress-circular>
<v-spacer/>
<v-btn density="comfortable" color="default" variant="text" class="ml-2"
:disabled="loading" :icon="true">
<v-icon :icon="icons.more" />
<v-menu activator="parent">
<v-list>
<v-list-item :prepend-icon="icons.filter"
:title="$t('Filter Accounts')"
@click="filterAccounts"></v-list-item>
<v-list-item :prepend-icon="icons.filter"
:title="$t('Filter Transaction Categories')"
@click="filterCategories"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="icons.filterSettings"
:title="$t('Settings')"
@click="settings"></v-list-item>
</v-list>
</v-menu>
</v-btn>
</div>
</template>
<v-card-text v-if="initing">
<v-skeleton-loader type="paragraph" :loading="initing"
:key="itemIdx" v-for="itemIdx in [ 1, 2, 3 ]"></v-skeleton-loader>
</v-card-text>
<v-card-title class="statistics-overview-title pt-0" v-if="!initing">
<div>{{ totalAmountName }}</div>
<div class="statistics-overview-amount ml-3" :class="statisticsTextColor"
v-if="statisticsData && statisticsData.items && statisticsData.items.length">
{{ getDisplayAmount(statisticsData.totalAmount, defaultCurrency) }}
</div>
<div class="text-subtitle-1 ml-3"
v-else-if="!statisticsData || !statisticsData.items || !statisticsData.items.length">
{{ $t('No transaction data') }}
</div>
</v-card-title>
<v-card-text v-if="!initing && query.chartType === allChartTypes.Pie">
<pie-chart
:items="statisticsData && statisticsData.items && statisticsData.items.length ? statisticsData.items : []"
:min-valid-percent="0.0001"
:show-value="showAmountInChart"
:enable-click-item="true"
:default-currency="defaultCurrency"
id-field="id"
name-field="name"
value-field="totalAmount"
percent-field="percent"
currency-field="currency"
hidden-field="hidden"
@click="clickPieChartItem"
/>
</v-card-text>
<v-card-text v-if="!initing && query.chartType === allChartTypes.Bar">
<v-list rounded lines="two"
v-if="statisticsData && statisticsData.items && statisticsData.items.length">
<template :key="idx"
v-for="(item, idx) in statisticsData.items">
<v-list-item class="pl-0" v-if="!item.hidden">
<template #prepend>
<router-link class="statistics-list-item" :to="getItemLinkUrl(item)">
<ItemIcon :icon-type="queryChartDataCategory" size="34px"
:icon-id="item.icon"
:color="item.color"></ItemIcon>
</router-link>
</template>
<router-link class="statistics-list-item" :to="getItemLinkUrl(item)">
<div class="d-flex flex-column ml-2">
<div class="d-flex">
<span>{{ item.name }}</span>
<small class="statistics-percent" v-if="item.percent >= 0">{{ getDisplayPercent(item.percent, 2, '&lt;0.01') }}</small>
<v-spacer/>
<span class="statistics-amount">{{ getDisplayAmount(item.totalAmount, (item.currency || defaultCurrency)) }}</span>
</div>
<div>
<v-progress-linear :color="item.color ? '#' + item.color : 'primary'"
:model-value="item.percent >= 0 ? item.percent : 0"
:height="4"></v-progress-linear>
</div>
</div>
</router-link>
</v-list-item>
<v-divider v-if="!item.hidden && idx !== statisticsData.items.length - 1"/>
</template>
</v-list>
</v-card-text>
</v-card>
</v-window-item>
</v-window>
</div>
</v-card>
</v-col>
</v-row>
<date-range-selection-dialog :title="$t('Custom Date Range')"
:min-time="query.startTime"
:max-time="query.endTime"
v-model:show="showCustomDateRangeDialog"
@dateRange:change="setCustomDateFilter" />
<snack-bar ref="snackbar" />
</template>
<script>
export default {
created() {
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
import { useAccountsStore } from '@/stores/account.js';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.js';
import { useStatisticsStore } from '@/stores/statistics.js';
import datetimeConstants from '@/consts/datetime.js';
import statisticsConstants from '@/consts/statistics.js';
import { getNameByKeyValue, limitText, formatPercent } from '@/lib/common.js'
import {
parseDateFromUnixTime,
getYear,
getShiftedDateRange,
getDateRangeByDateType,
isDateRangeMatchFullYears,
isDateRangeMatchFullMonths
} from '@/lib/datetime.js';
import {
mdiCheck,
mdiArrowLeft,
mdiArrowRight,
mdiSort,
mdiRefresh,
mdiFilterOutline,
mdiFilterCogOutline,
mdiPencilOutline,
mdiDotsVertical,
} from '@mdi/js';
export default {
data() {
return {
activeTab: 'statisticsPage',
initing: true,
loading: true,
showCustomDateRangeDialog: false,
icons: {
check: mdiCheck,
left: mdiArrowLeft,
right: mdiArrowRight,
sort: mdiSort,
refresh: mdiRefresh,
filter: mdiFilterOutline,
filterSettings: mdiFilterCogOutline,
pencil: mdiPencilOutline,
more: mdiDotsVertical
}
};
},
computed: {
...mapStores(useSettingsStore, useUserStore, useAccountsStore, useTransactionCategoriesStore, useStatisticsStore),
defaultCurrency() {
return this.userStore.currentUserDefaultCurrency;
},
firstDayOfWeek() {
return this.userStore.currentUserFirstDayOfWeek;
},
query() {
return this.statisticsStore.transactionStatisticsFilter;
},
queryChartDataCategory() {
return this.statisticsStore.transactionStatisticsChartDataCategory;
},
querySortingTypeName() {
const querySortingTypeName = getNameByKeyValue(this.allSortingTypes, this.query.sortingType, 'type', 'fullName', 'System Default');
return this.$t(querySortingTypeName);
},
queryStartTime() {
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, this.query.startTime);
},
queryEndTime() {
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, this.query.endTime);
},
allChartTypes() {
return statisticsConstants.allChartTypes;
},
allChartDataTypes() {
return statisticsConstants.allChartDataTypes;
},
allSortingTypes() {
return statisticsConstants.allSortingTypes;
},
allDateRanges() {
return datetimeConstants.allDateRanges;
},
showAccountBalance() {
return this.settingsStore.appSettings.showAccountBalance;
},
totalAmountName() {
if (this.query.chartDataType === this.allChartDataTypes.IncomeByAccount.type
|| this.query.chartDataType === this.allChartDataTypes.IncomeByPrimaryCategory.type
|| this.query.chartDataType === this.allChartDataTypes.IncomeBySecondaryCategory.type) {
return this.$t('Total Income');
} else if (this.query.chartDataType === this.allChartDataTypes.ExpenseByAccount.type
|| this.query.chartDataType === this.allChartDataTypes.ExpenseByPrimaryCategory.type
|| this.query.chartDataType === this.allChartDataTypes.ExpenseBySecondaryCategory.type) {
return this.$t('Total Expense');
} else if (this.query.chartDataType === this.allChartDataTypes.AccountTotalAssets.type) {
return this.$t('Total Assets');
} else if (this.query.chartDataType === this.allChartDataTypes.AccountTotalLiabilities.type) {
return this.$t('Total Liabilities');
}
return this.$t('Total Amount');
},
statisticsData() {
return this.statisticsStore.statisticsData;
},
statisticsTextColor() {
if (this.query.chartDataType === this.allChartDataTypes.ExpenseByAccount.type ||
this.query.chartDataType === this.allChartDataTypes.ExpenseByPrimaryCategory.type ||
this.query.chartDataType === this.allChartDataTypes.ExpenseBySecondaryCategory.type) {
return 'text-expense';
} else if (this.query.chartDataType === this.allChartDataTypes.IncomeByAccount.type ||
this.query.chartDataType === this.allChartDataTypes.IncomeByPrimaryCategory.type ||
this.query.chartDataType === this.allChartDataTypes.IncomeBySecondaryCategory.type) {
return 'text-income';
} else {
return 'text-default';
}
},
showAmountInChart() {
if (!this.showAccountBalance
&& (this.query.chartDataType === this.allChartDataTypes.AccountTotalAssets.type || this.query.chartDataType === this.allChartDataTypes.AccountTotalLiabilities.type)) {
return false;
}
return true;
}
},
watch: {
'query.chartDataType': function (newValue) {
this.statisticsStore.updateTransactionStatisticsFilter({
chartDataType: newValue
});
}
},
created() {
const self = this;
let defaultChartType = self.settingsStore.appSettings.statistics.defaultChartType;
if (defaultChartType !== self.allChartTypes.Pie && defaultChartType !== self.allChartTypes.Bar) {
defaultChartType = statisticsConstants.defaultChartType;
}
let defaultChartDataType = self.settingsStore.appSettings.statistics.defaultChartDataType;
if (defaultChartDataType < self.allChartDataTypes.ExpenseByAccount.type || defaultChartDataType > self.allChartDataTypes.AccountTotalLiabilities.type) {
defaultChartDataType = statisticsConstants.defaultChartDataType;
}
let defaultDateRange = self.settingsStore.appSettings.statistics.defaultDataRangeType;
if (defaultDateRange < self.allDateRanges.All.type || defaultDateRange >= self.allDateRanges.Custom.type) {
defaultDateRange = statisticsConstants.defaultDataRangeType;
}
let defaultSortType = self.settingsStore.appSettings.statistics.defaultSortingType;
if (defaultSortType < self.allSortingTypes.Amount.type || defaultSortType > self.allSortingTypes.Name.type) {
defaultSortType = statisticsConstants.defaultSortingType;
}
const dateRange = getDateRangeByDateType(defaultDateRange, self.firstDayOfWeek);
self.statisticsStore.initTransactionStatisticsFilter({
dateType: dateRange ? dateRange.dateType : undefined,
startTime: dateRange ? dateRange.minTime : undefined,
endTime: dateRange ? dateRange.maxTime : undefined,
chartType: defaultChartType,
chartDataType: defaultChartDataType,
filterAccountIds: self.settingsStore.appSettings.statistics.defaultAccountFilter || {},
filterCategoryIds: self.settingsStore.appSettings.statistics.defaultTransactionCategoryFilter || {},
sortingType: defaultSortType,
});
Promise.all([
self.accountsStore.loadAllAccounts({ force: false }),
self.transactionCategoriesStore.loadAllCategories({ force: false })
]).then(() => {
return self.statisticsStore.loadTransactionStatistics({
force: false
});
}).then(() => {
self.loading = false;
self.initing = false;
}).catch(error => {
self.loading = false;
self.initing = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
methods: {
reload(force) {
const self = this;
let dispatchPromise = null;
self.loading = true;
if (self.query.chartDataType === self.allChartDataTypes.ExpenseByAccount.type ||
self.query.chartDataType === self.allChartDataTypes.ExpenseByPrimaryCategory.type ||
self.query.chartDataType === self.allChartDataTypes.ExpenseBySecondaryCategory.type ||
self.query.chartDataType === self.allChartDataTypes.IncomeByAccount.type ||
self.query.chartDataType === self.allChartDataTypes.IncomeByPrimaryCategory.type ||
self.query.chartDataType === self.allChartDataTypes.IncomeBySecondaryCategory.type) {
dispatchPromise = self.statisticsStore.loadTransactionStatistics({
force: force
});
} else if (self.query.chartDataType === self.allChartDataTypes.AccountTotalAssets.type ||
self.query.chartDataType === self.allChartDataTypes.AccountTotalLiabilities.type) {
dispatchPromise = self.accountsStore.loadAllAccounts({
force: force
});
}
if (dispatchPromise) {
dispatchPromise.then(() => {
self.loading = false;
if (force) {
self.$refs.snackbar.showMessage('Data has been updated');
}
}).catch(error => {
self.loading = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
}
},
setChartType(chartType) {
this.statisticsStore.updateTransactionStatisticsFilter({
chartType: chartType
});
},
setSortingType(sortingType) {
if (sortingType < this.allSortingTypes.Amount.type || sortingType > this.allSortingTypes.Name.type) {
return;
}
this.statisticsStore.updateTransactionStatisticsFilter({
sortingType: sortingType
});
this.reload(null);
},
setDateFilter(dateType) {
if (dateType === this.allDateRanges.Custom.type) { // Custom
this.showCustomDateRangeDialog = true;
return;
} else if (this.query.dateType === dateType) {
return;
}
const dateRange = getDateRangeByDateType(dateType, this.firstDayOfWeek);
if (!dateRange) {
return;
}
this.statisticsStore.updateTransactionStatisticsFilter({
dateType: dateRange.dateType,
startTime: dateRange.minTime,
endTime: dateRange.maxTime
});
this.reload(null);
},
setCustomDateFilter(startTime, endTime) {
if (!startTime || !endTime) {
return;
}
this.statisticsStore.updateTransactionStatisticsFilter({
dateType: this.allDateRanges.Custom.type,
startTime: startTime,
endTime: endTime
});
this.showCustomDateRangeDialog = false;
this.reload(null);
},
shiftDateRange(startTime, endTime, scale) {
if (this.query.dateType === this.allDateRanges.All.type) {
return;
}
const newDateRange = getShiftedDateRange(startTime, endTime, scale);
let newDateType = this.allDateRanges.Custom.type;
for (let dateRangeField in this.allDateRanges) {
if (!Object.prototype.hasOwnProperty.call(this.allDateRanges, dateRangeField)) {
continue;
}
const dateRangeType = this.allDateRanges[dateRangeField];
const dateRange = getDateRangeByDateType(dateRangeType.type, this.firstDayOfWeek);
if (dateRange && dateRange.minTime === newDateRange.minTime && dateRange.maxTime === newDateRange.maxTime) {
newDateType = dateRangeType.type;
break;
}
}
this.statisticsStore.updateTransactionStatisticsFilter({
dateType: newDateType,
startTime: newDateRange.minTime,
endTime: newDateRange.maxTime
});
this.reload(null);
},
dateRangeName(query) {
if (query.chartDataType === this.allChartDataTypes.AccountTotalAssets.type ||
query.chartDataType === this.allChartDataTypes.AccountTotalLiabilities.type) {
return this.$t(this.allDateRanges.All.name);
}
if (query.dateType === this.allDateRanges.All.type) {
return this.$t(this.allDateRanges.All.name);
}
for (let dateRangeField in this.allDateRanges) {
if (!Object.prototype.hasOwnProperty.call(this.allDateRanges, dateRangeField)) {
continue;
}
const dateRange = this.allDateRanges[dateRangeField];
if (dateRange && dateRange.type !== this.allDateRanges.Custom.type && dateRange.type === query.dateType && dateRange.name) {
return this.$t(dateRange.name);
}
}
if (isDateRangeMatchFullYears(query.startTime, query.endTime)) {
const displayStartTime = this.$locale.formatUnixTimeToShortYear(this.userStore, query.startTime);
const displayEndTime = this.$locale.formatUnixTimeToShortYear(this.userStore, query.endTime);
return displayStartTime !== displayEndTime ? `${displayStartTime} ~ ${displayEndTime}` : displayStartTime;
}
if (isDateRangeMatchFullMonths(query.startTime, query.endTime)) {
const displayStartTime = this.$locale.formatUnixTimeToShortYearMonth(this.userStore, query.startTime);
const displayEndTime = this.$locale.formatUnixTimeToShortYearMonth(this.userStore, query.endTime);
return displayStartTime !== displayEndTime ? `${displayStartTime} ~ ${displayEndTime}` : displayStartTime;
}
const startTimeYear = getYear(parseDateFromUnixTime(query.startTime));
const endTimeYear = getYear(parseDateFromUnixTime(query.endTime));
const displayStartTime = this.$locale.formatUnixTimeToShortDate(this.userStore, query.startTime);
const displayEndTime = this.$locale.formatUnixTimeToShortDate(this.userStore, query.endTime);
if (displayStartTime === displayEndTime) {
return displayStartTime;
} else if (startTimeYear === endTimeYear) {
const displayShortEndTime = this.$locale.formatUnixTimeToShortMonthDay(this.userStore, query.endTime);
return `${displayStartTime} ~ ${displayShortEndTime}`;
}
return `${displayStartTime} ~ ${displayEndTime}`;
},
clickPieChartItem(item) {
this.$router.push(this.getItemLinkUrl(item));
},
filterAccounts() {
},
filterCategories() {
},
settings() {
this.$router.push('/app/settings?tab=statisticsSetting');
},
getDisplayAmount(amount, currency, textLimit) {
amount = this.getDisplayCurrency(amount, currency);
if (!this.showAccountBalance
&& (this.query.chartDataType === this.allChartDataTypes.AccountTotalAssets.type
|| this.query.chartDataType === this.allChartDataTypes.AccountTotalLiabilities.type)
) {
return '***';
}
if (textLimit) {
return limitText(amount, textLimit);
}
return amount;
},
getDisplayCurrency(value, currencyCode) {
return this.$locale.getDisplayCurrency(value, currencyCode, {
currencyDisplayMode: this.settingsStore.appSettings.currencyDisplayMode,
enableThousandsSeparator: this.settingsStore.appSettings.thousandsSeparator
});
},
getDisplayPercent(value, precision, lowPrecisionValue) {
return formatPercent(value, precision, lowPrecisionValue);
},
getItemLinkUrl(item) {
return `/transactions?${this.statisticsStore.getTransactionListPageParams(item)}`;
}
}
}
</script>
<style>
.statistics-container.v-window > .v-window__container {
width: 100%;
}
.statistics-toolbar {
overflow-x: auto;
white-space: nowrap;
}
.statistics-overview-title {
line-height: 2rem !important;
display: flex;
align-items: flex-end;
}
.statistics-overview-amount {
font-size: 1.5rem;
overflow: hidden;
text-overflow: ellipsis;
}
.statistics-list-item {
color: var(--v-theme-on-default);
font-size: 1rem !important;
line-height: 1.75rem;
overflow: hidden;
text-overflow: ellipsis;
}
.statistics-list-item .statistics-percent {
font-size: 0.7rem;
opacity: 0.7;
margin-left: 6px;
}
.statistics-list-item .statistics-amount {
opacity: 0.8;
}
</style>
+12
View File
@@ -170,6 +170,18 @@
"url": "https://github.com/nolimits4web/skeleton-elements",
"licenseUrl": "https://github.com/nolimits4web/skeleton-elements/blob/v4.0.1/LICENSE"
},
{
"name": "Apache ECharts",
"copyright": "Copyright © 2017-2023, The Apache Software Foundation Apache ECharts, ECharts, Apache, the Apache feather, and the Apache ECharts project logo are either registered trademarks or trademarks of the Apache Software Foundation.",
"url": "https://echarts.apache.org/",
"licenseUrl": "https://github.com/apache/echarts/blob/5.4.2/LICENSE"
},
{
"name": "vue-echarts",
"copyright": "Copyright (c) 2016-present GU Yiling & ECOMFE",
"url": "https://github.com/ecomfe/vue-echarts",
"licenseUrl": "https://github.com/ecomfe/vue-echarts/blob/v6.6.0/LICENSE"
},
{
"name": "vue3-perfect-scrollbar",
"copyright": "Copyright (c) 2018 Adam",
+1 -1
View File
@@ -142,7 +142,7 @@ export default defineConfig(async () => {
return 'moment';
} else if (/[\\/]node_modules[\\/](dom7|framework7.*|skeleton-elements|swiper)[\\/]/i.test(id)) {
return 'vendor-mobile';
} else if (/[\\/]node_modules[\\/](vuetify|vue-router|vue3-perfect-scrollbar|@mdi.*)[\\/]/i.test(id)) {
} else if (/[\\/]node_modules[\\/](vuetify|vue-router|vue3-perfect-scrollbar|@mdi.*|echarts|vue-echarts)[\\/]/i.test(id)) {
return 'vendor-desktop';
} else if (/[\\/]node_modules[\\/]/i.test(id)) {
return 'vendor-common';