mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-15 15:37:33 +08:00
add transaction statistics page
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user