diff --git a/package-lock.json b/package-lock.json index b80251c8..efab47c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4623,6 +4623,22 @@ "safer-buffer": "^2.1.0" } }, + "echarts": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.0.1.tgz", + "integrity": "sha512-JYn22Dolt2esY2jEzUsw1OxbobuW67oGjIoTjZO3rW89SWkfJ4kbrmC2OW9JjsBrD1rdkmaWBuZZ2HgmThyxJw==", + "requires": { + "tslib": "2.0.3", + "zrender": "5.0.3" + }, + "dependencies": { + "tslib": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz", + "integrity": "sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==" + } + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7058,8 +7074,7 @@ "lodash": { "version": "4.17.20", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", - "dev": true + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" }, "lodash.defaultsdeep": { "version": "4.6.1", @@ -9346,6 +9361,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "resize-detector": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/resize-detector/-/resize-detector-0.1.10.tgz", + "integrity": "sha512-iLcXC8A6Fb0DfA+TRiywrK/0A22bFqkhntjMJMEzXDA4XkcEkfwpNbv7W8iewUiD0xYIaeiXOfiEehTqGKsUFw==" + }, "resolve": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", @@ -11081,6 +11101,16 @@ "clipboard": "^2.0.0" } }, + "vue-echarts": { + "version": "5.0.0-beta.0", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-5.0.0-beta.0.tgz", + "integrity": "sha512-QZFKGXDAYFQo+F20REpzcdLx79nsl4kOorJRpN+08aYq4YiIlmtWss1Lxadm7Fo+NYyWm8nnT+h4xHv3uqWIDQ==", + "requires": { + "core-js": "^3.4.4", + "lodash": "^4.17.15", + "resize-detector": "^0.1.10" + } + }, "vue-eslint-parser": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-7.1.1.tgz", @@ -12144,6 +12174,21 @@ "dev": true } } + }, + "zrender": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.0.3.tgz", + "integrity": "sha512-TVcN2IMdo7je3GEq/E4CER4AGBe/n50/izILdupppyHf/hVHuiXCRliqdu8+32Z1OmGg6RfKt5qQlkX+bOtU0g==", + "requires": { + "tslib": "2.0.3" + }, + "dependencies": { + "tslib": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz", + "integrity": "sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==" + } + } } } } diff --git a/package.json b/package.json index c1d31439..11b904c1 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "cbor-js": "^0.1.0", "core-js": "^3.6.5", "crypto-js": "^4.0.0", + "echarts": "^5.0.1", "framework7": "^5.7.14", "framework7-icons": "^3.0.1", "framework7-vue": "^5.7.14", @@ -21,6 +22,7 @@ "ua-parser-js": "^0.7.22", "vue": "^2.6.12", "vue-clipboard2": "^0.3.1", + "vue-echarts": "^5.0.0-beta.0", "vue-i18n": "^8.22.1", "vue-i18n-filter": "^0.1.6", "vue-moment": "^4.1.0", diff --git a/src/consts/statistics.js b/src/consts/statistics.js new file mode 100644 index 00000000..36fe2a9b --- /dev/null +++ b/src/consts/statistics.js @@ -0,0 +1,21 @@ +const allChartTypes = { + Pie: 0, + Bar: 1 +}; + +const defaultChartType = allChartTypes.Pie; + +const allChartLegendTypes = { + Account: 0, + PrimaryCategory: 1, + SecondaryCategory: 1 +}; + +const defaultChartLegendType = allChartLegendTypes.SecondaryCategory; + +export default { + allChartTypes: allChartTypes, + defaultChartType: defaultChartType, + allChartLegendTypes: allChartLegendTypes, + defaultChartLegendType: defaultChartLegendType, +}; diff --git a/src/lib/services.js b/src/lib/services.js index 3fa8ca63..4063860c 100644 --- a/src/lib/services.js +++ b/src/lib/services.js @@ -185,6 +185,19 @@ export default { return axios.get('v1/overviews/transaction.json' + (queryParams.length ? '?query=' + queryParams.join('|') : '')); }, + getTransactionStatistics: ({ startTime, endTime }) => { + const queryParams = []; + + if (startTime) { + queryParams.push(`start_time=${startTime}`); + } + + if (endTime) { + queryParams.push(`end_time=${endTime}`); + } + + return axios.get('v1/statistics/transaction.json' + (queryParams.length ? '?' + queryParams.join('&') : '')); + }, getAllAccounts: ({ visibleOnly }) => { return axios.get('v1/accounts/list.json?visible_only=' + !!visibleOnly); }, diff --git a/src/mobile-main.js b/src/mobile-main.js index 6c8b4f80..aea34706 100644 --- a/src/mobile-main.js +++ b/src/mobile-main.js @@ -4,6 +4,7 @@ import VueI18n from 'vue-i18n'; import VueI18nFilter from 'vue-i18n-filter'; import Framework7 from 'framework7/framework7.esm.bundle.js'; import Framework7Vue from 'framework7-vue/framework7-vue.esm.bundle.js'; +import ECharts from 'vue-echarts'; import PincodeInput from 'vue-pincode-input'; import VueMoment from 'vue-moment'; import VueClipboard from 'vue-clipboard2'; @@ -15,6 +16,13 @@ import 'framework7-icons'; import 'line-awesome/dist/line-awesome/css/line-awesome.css'; +import 'echarts/lib/chart/line'; +import 'echarts/lib/chart/pie'; +import 'echarts/lib/chart/bar'; +import 'echarts/lib/component/legend'; +import 'echarts/lib/component/title'; +import 'echarts/lib/component/tooltip'; + import { getAllLanguages, getLanguage, getDefaultLanguage, getI18nOptions, getLocalizedError, getLocalizedErrorParameters } from './lib/i18n.js'; import api from './consts/api.js'; import datetime from './consts/datetime.js'; @@ -24,6 +32,7 @@ import icons from './consts/icon.js'; import account from './consts/account.js'; import transaction from './consts/transaction.js'; import category from './consts/category.js'; +import statistics from './consts/statistics.js'; import licenses from './lib/licenses.js'; import version from './lib/version.js'; import logger from './lib/logger.js'; @@ -62,6 +71,7 @@ Vue.use(VueI18n); Vue.use(VueI18nFilter); Vue.use(VueMoment, { moment }); Vue.use(VueClipboard); +Vue.component('v-chart', ECharts); Vue.component('PincodeInput', PincodeInput); Vue.component('PasswordInputSheet', PasswordInputSheet); Vue.component('PasscodeInputSheet', PasscodeInputSheet); @@ -92,6 +102,7 @@ Vue.prototype.$constants = { account: account, transaction: transaction, category: category, + statistics: statistics, }; Vue.prototype.$utilities = utils; diff --git a/src/router/mobile.js b/src/router/mobile.js index 3e9d177e..50c372c1 100644 --- a/src/router/mobile.js +++ b/src/router/mobile.js @@ -11,7 +11,7 @@ import TransactionEditPage from '../views/mobile/transactions/Edit.vue'; import AccountListPage from '../views/mobile/accounts/List.vue'; import AccountEditPage from '../views/mobile/accounts/Edit.vue'; -import StatisticsOverviewPage from '../views/mobile/statistics/Overview.vue'; +import StatisticsTransactionPage from '../views/mobile/statistics/Transaction.vue'; import SettingsPage from '../views/mobile/Settings.vue'; import ApplicationLockPage from '../views/mobile/ApplicationLock.vue'; @@ -171,8 +171,8 @@ const routes = [ beforeEnter: checkLogin }, { - path: '/statistic/overview', - component: StatisticsOverviewPage, + path: '/statistic/transaction', + component: StatisticsTransactionPage, beforeEnter: checkLogin }, { diff --git a/src/store/index.js b/src/store/index.js index 83646005..d7045660 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,4 +1,5 @@ import datetimeConstants from "../consts/datetime.js"; +import statisticsConstants from "../consts/statistics.js"; import userState from "../lib/userstate.js"; import utils from "../lib/utils.js"; @@ -44,6 +45,11 @@ import { LOAD_TRANSACTION_OVERVIEW, UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, + + LOAD_TRANSACTION_STATISTICS, + INIT_TRANSACTION_STATISTICS_FILTER, + UPDATE_TRANSACTION_STATISTICS_FILTER, + UPDATE_TRANSACTION_STATISTICS_INVALID_STATE, } from './mutations.js'; import { @@ -88,6 +94,12 @@ import { loadTransactionOverview } from './overview.js'; +import { + loadTransactionStatistics, + initTransactionStatisticsFilter, + updateTransactionStatisticsFilter +} from './statistics.js'; + import { loadAllAccounts, getAccount, @@ -165,6 +177,15 @@ const stores = { transactionTagListStateInvalid: true, transactionOverview: {}, transactionOverviewStateInvalid: true, + transactionStatisticsFilter: { + dateType: datetimeConstants.allDateRanges.ThisMonth.type, + startTime: 0, + endTime: 0, + chartType: statisticsConstants.defaultChartType, + chartLegendType: statisticsConstants.defaultChartLegendType, + }, + transactionStatistics: [], + transactionStatisticsStateInvalid: true, }, getters: { // user @@ -216,6 +237,14 @@ const stores = { state.transactionOverview = {}; state.transactionOverviewStateInvalid = true; + state.transactionStatisticsFilter.dateType = datetimeConstants.allDateRanges.ThisMonth.type; + state.transactionStatisticsFilter.startTime = 0; + state.transactionStatisticsFilter.endTime = 0; + state.transactionStatisticsFilter.chartType = statisticsConstants.defaultChartType; + state.transactionStatisticsFilter.chartLegendType = statisticsConstants.defaultChartLegendType; + state.transactionStatistics = {}; + state.transactionStatisticsStateInvalid = true; + clearExchangeRatesFromLocalStorage(); }, [STORE_USER_INFO] (state, userInfo) { @@ -731,6 +760,86 @@ const stores = { [UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE] (state, invalidState) { state.transactionOverviewStateInvalid = invalidState; }, + [LOAD_TRANSACTION_STATISTICS] (state, { statistics, defaultCurrency }) { + if (statistics && statistics.items && statistics.items.length) { + for (let i = 0; i < statistics.items.length; i++) { + const item = statistics.items[i]; + + if (item.accountId) { + item.account = state.allAccountsMap[item.accountId]; + } + + if (item.categoryId) { + item.category = state.allTransactionCategoriesMap[item.categoryId]; + } + + if (item.account && item.account.currency !== defaultCurrency) { + item.amountInDefaultCurrency = getExchangedAmount(state)(item.amount, item.account.currency, defaultCurrency); + } else if (item.account && item.account.currency === defaultCurrency) { + item.amountInDefaultCurrency = item.amount; + } else { + item.amountInDefaultCurrency = null; + } + } + } + + state.transactionStatistics = statistics; + }, + [INIT_TRANSACTION_STATISTICS_FILTER] (state, filter) { + if (filter && utils.isNumber(filter.dateType)) { + state.transactionStatisticsFilter.dateType = filter.dateType; + } else { + state.transactionStatisticsFilter.dateType = datetimeConstants.allDateRanges.ThisMonth.type; + } + + if (filter && utils.isNumber(filter.startTime)) { + state.transactionStatisticsFilter.startTime = filter.startTime; + } else { + state.transactionStatisticsFilter.startTime = 0; + } + + if (filter && utils.isNumber(filter.endTime)) { + state.transactionStatisticsFilter.endTime = filter.endTime; + } else { + state.transactionStatisticsFilter.endTime = 0; + } + + if (filter && utils.isNumber(filter.chartType)) { + state.transactionStatisticsFilter.chartType = filter.chartType; + } else { + state.transactionStatisticsFilter.chartType = statisticsConstants.defaultChartType; + } + + if (filter && utils.isNumber(filter.chartLegendType)) { + state.transactionStatisticsFilter.chartLegendType = filter.chartLegendType; + } else { + state.transactionStatisticsFilter.chartLegendType = statisticsConstants.defaultChartLegendType; + } + }, + [UPDATE_TRANSACTION_STATISTICS_FILTER] (state, filter) { + if (filter && utils.isNumber(filter.dateType)) { + state.transactionStatisticsFilter.dateType = filter.dateType; + } + + if (filter && utils.isNumber(filter.startTime)) { + state.transactionStatisticsFilter.startTime = filter.startTime; + } + + if (filter && utils.isNumber(filter.endTime)) { + state.transactionStatisticsFilter.endTime = filter.endTime; + } + + if (filter && utils.isNumber(filter.chartType)) { + state.transactionStatisticsFilter.chartType = filter.chartType; + } + + if (filter && utils.isNumber(filter.chartLegendType)) { + state.transactionStatisticsFilter.chartLegendType = filter.chartLegendType; + } + }, + [UPDATE_TRANSACTION_STATISTICS_INVALID_STATE] (state, invalidState) { + state.transactionStatisticsStateInvalid = invalidState; + }, }, actions: { // user @@ -763,6 +872,11 @@ const stores = { // overview loadTransactionOverview, + // statistics + loadTransactionStatistics, + initTransactionStatisticsFilter, + updateTransactionStatisticsFilter, + // account loadAllAccounts, saveAccount, diff --git a/src/store/mutations.js b/src/store/mutations.js index c90938fb..ccb100a0 100644 --- a/src/store/mutations.js +++ b/src/store/mutations.js @@ -39,3 +39,8 @@ export const UPDATE_TRANSACTION_TAG_LIST_INVALID_STATE = 'UPDATE_TRANSACTION_TAG export const LOAD_TRANSACTION_OVERVIEW = 'LOAD_TRANSACTION_OVERVIEW'; export const UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE = 'UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE'; + +export const LOAD_TRANSACTION_STATISTICS = 'LOAD_TRANSACTION_STATISTICS'; +export const INIT_TRANSACTION_STATISTICS_FILTER = 'INIT_TRANSACTION_STATISTICS_FILTER'; +export const UPDATE_TRANSACTION_STATISTICS_FILTER = 'UPDATE_TRANSACTION_STATISTICS_FILTER'; +export const UPDATE_TRANSACTION_STATISTICS_INVALID_STATE = 'UPDATE_TRANSACTION_STATISTICS_INVALID_STATE'; diff --git a/src/store/statistics.js b/src/store/statistics.js new file mode 100644 index 00000000..c1b70dab --- /dev/null +++ b/src/store/statistics.js @@ -0,0 +1,54 @@ +import services from '../lib/services.js'; +import logger from '../lib/logger.js'; + +import { + LOAD_TRANSACTION_STATISTICS, + INIT_TRANSACTION_STATISTICS_FILTER, + UPDATE_TRANSACTION_STATISTICS_FILTER, + UPDATE_TRANSACTION_STATISTICS_INVALID_STATE +} from "./mutations.js"; + +export function loadTransactionStatistics(context, { defaultCurrency }) { + return new Promise((resolve, reject) => { + services.getTransactionStatistics({ + startTime: context.state.transactionStatisticsFilter.startTime, + endTime: context.state.transactionStatisticsFilter.endTime + }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to get transaction statistics' }); + return; + } + + context.commit(LOAD_TRANSACTION_STATISTICS, { + statistics: data.result, + defaultCurrency: defaultCurrency + }); + + if (context.state.transactionStatisticsStateInvalid) { + context.commit(UPDATE_TRANSACTION_STATISTICS_INVALID_STATE, false); + } + + resolve(data.result); + }).catch(error => { + logger.error('failed to get transaction statistics', error); + + if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else if (!error.processed) { + reject({ message: 'Unable to get transaction statistics' }); + } else { + reject(error); + } + }); + }); +} + +export function initTransactionStatisticsFilter(context, filter) { + context.commit(INIT_TRANSACTION_STATISTICS_FILTER, filter); +} + +export function updateTransactionStatisticsFilter(context, filter) { + context.commit(UPDATE_TRANSACTION_STATISTICS_FILTER, filter); +} diff --git a/src/store/transaction.js b/src/store/transaction.js index 4155b7c1..f1a12182 100644 --- a/src/store/transaction.js +++ b/src/store/transaction.js @@ -15,6 +15,7 @@ import { UPDATE_TRANSACTION_LIST_INVALID_STATE, UPDATE_ACCOUNT_LIST_INVALID_STATE, UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, + UPDATE_TRANSACTION_STATISTICS_INVALID_STATE, } from './mutations.js'; const emptyTransactionResult = { @@ -177,6 +178,10 @@ export function saveTransaction(context, { transaction, defaultCurrency }) { context.commit(UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, true); } + if (!context.state.transactionStatisticsStateInvalid) { + context.commit(UPDATE_TRANSACTION_STATISTICS_INVALID_STATE, true); + } + resolve(data.result); }).catch(error => { logger.error('failed to save transaction', error); @@ -230,6 +235,10 @@ export function deleteTransaction(context, { transaction, defaultCurrency, befor context.commit(UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, true); } + if (!context.state.transactionStatisticsStateInvalid) { + context.commit(UPDATE_TRANSACTION_STATISTICS_INVALID_STATE, true); + } + resolve(data.result); }).catch(error => { logger.error('failed to delete transaction', error); diff --git a/src/store/user.js b/src/store/user.js index 8d864de2..e059c738 100644 --- a/src/store/user.js +++ b/src/store/user.js @@ -13,7 +13,8 @@ import { UPDATE_ACCOUNT_LIST_INVALID_STATE, UPDATE_TRANSACTION_CATEGORY_LIST_INVALID_STATE, UPDATE_TRANSACTION_TAG_LIST_INVALID_STATE, - UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE + UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, + UPDATE_TRANSACTION_STATISTICS_INVALID_STATE } from './mutations.js'; export function authorize(context, { loginName, password }) { @@ -258,6 +259,10 @@ export function updateUserProfile(context, { profile, currentPassword }) { context.commit(UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, true); } + if (!context.state.transactionStatisticsStateInvalid) { + context.commit(UPDATE_TRANSACTION_STATISTICS_INVALID_STATE, true); + } + resolve(data.result); }).catch(error => { logger.error('failed to save user profile', error); @@ -301,6 +306,10 @@ export function clearUserData(context, { password }) { context.commit(UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, true); } + if (!context.state.transactionStatisticsStateInvalid) { + context.commit(UPDATE_TRANSACTION_STATISTICS_INVALID_STATE, true); + } + resolve(data.result); }).catch(error => { logger.error('failed to clear user data', error); diff --git a/src/views/mobile/Home.vue b/src/views/mobile/Home.vue index 5217f08e..7e5e747b 100644 --- a/src/views/mobile/Home.vue +++ b/src/views/mobile/Home.vue @@ -155,7 +155,7 @@ - + {{ $t('Statistics') }} diff --git a/src/views/mobile/statistics/Overview.vue b/src/views/mobile/statistics/Overview.vue deleted file mode 100644 index 66c9d6e1..00000000 --- a/src/views/mobile/statistics/Overview.vue +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/src/views/mobile/statistics/Transaction.vue b/src/views/mobile/statistics/Transaction.vue new file mode 100644 index 00000000..9c45b134 --- /dev/null +++ b/src/views/mobile/statistics/Transaction.vue @@ -0,0 +1,178 @@ + + + + +