add transaction statistics page
This commit is contained in:
Generated
+74
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '<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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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> - </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, '<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>
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user