mirror of
				https://github.com/firefly-iii/firefly-iii.git
				synced 2025-10-31 10:47:00 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			394 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			394 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /*
 | |
|  * budgets.js
 | |
|  * Copyright (c) 2023 james@firefly-iii.org
 | |
|  *
 | |
|  * This file is part of Firefly III (https://github.com/firefly-iii).
 | |
|  *
 | |
|  * This program is free software: you can redistribute it and/or modify
 | |
|  * it under the terms of the GNU Affero General Public License as
 | |
|  * published by the Free Software Foundation, either version 3 of the
 | |
|  * License, or (at your option) any later version.
 | |
|  *
 | |
|  * This program is distributed in the hope that it will be useful,
 | |
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of
 | |
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | |
|  * GNU Affero General Public License for more details.
 | |
|  *
 | |
|  * You should have received a copy of the GNU Affero General Public License
 | |
|  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 | |
|  */
 | |
| import {getVariable} from "../../store/get-variable.js";
 | |
| import Get from "../../api/v2/model/transaction/get.js";
 | |
| import {getDefaultChartSettings} from "../../support/default-chart-settings.js";
 | |
| import {Chart} from 'chart.js';
 | |
| import {Flow, SankeyController} from 'chartjs-chart-sankey';
 | |
| import {getCacheKey} from "../../support/get-cache-key.js";
 | |
| import {format} from "date-fns";
 | |
| import i18next from "i18next";
 | |
| 
 | |
| Chart.register({SankeyController, Flow});
 | |
| 
 | |
| const SANKEY_CACHE_KEY = 'dashboard-sankey-data';
 | |
| let currencies = [];
 | |
| let afterPromises = false;
 | |
| let chart = null;
 | |
| let transactions = [];
 | |
| let autoConversion = false;
 | |
| let translations = {
 | |
|     category: null,
 | |
|     unknown_category: null,
 | |
|     in: null,
 | |
|     out: null,
 | |
|     // TODO
 | |
|     unknown_source: null,
 | |
|     unknown_dest: null,
 | |
|     unknown_account: null,
 | |
|     expense_account: null,
 | |
|     revenue_account: null,
 | |
|     budget: null,
 | |
|     unknown_budget: null,
 | |
|     all_money: null,
 | |
| };
 | |
| 
 | |
| const colors = {
 | |
|     a: 'red',
 | |
|     b: 'green',
 | |
|     c: 'blue',
 | |
|     d: 'gray'
 | |
| };
 | |
| 
 | |
| const getColor = function (key) {
 | |
|     if (key.includes(translations.revenue_account)) {
 | |
|         return 'forestgreen';
 | |
|     }
 | |
|     if (key.includes('(' + translations.in + ',')) {
 | |
|         return 'green';
 | |
|     }
 | |
| 
 | |
|     if (key.includes(translations.budget) || key.includes(translations.unknown_budget)) {
 | |
|         return 'Orchid';
 | |
|     }
 | |
|     if (key.includes('(' + translations.out + ',')) {
 | |
|         return 'MediumOrchid';
 | |
|     }
 | |
| 
 | |
|     if (key.includes(translations.all_money)) {
 | |
|         return 'blue';
 | |
|     }
 | |
|     return 'red';
 | |
| }
 | |
| 
 | |
| // little helper
 | |
| function getObjectName(type, name, direction, code) {
 | |
| 
 | |
|     // category 4x
 | |
|     if ('category' === type && null !== name && 'in' === direction) {
 | |
|         return translations.category + ' "' + name + '" (' + translations.in + (autoConversion ? ', ' + code + ')' : ')');
 | |
|     }
 | |
|     if ('category' === type && null === name && 'in' === direction) {
 | |
|         return translations.unknown_category + ' (' + translations.in + (autoConversion ? ', ' + code + ')' : ')');
 | |
|     }
 | |
|     if ('category' === type && null !== name && 'out' === direction) {
 | |
|         return translations.category + ' "' + name + '" (' + translations.out + (autoConversion ? ', ' + code + ')' : ')');
 | |
|     }
 | |
|     if ('category' === type && null === name && 'out' === direction) {
 | |
|         return translations.unknown_category + ' (' + translations.out + (autoConversion ? ', ' + code + ')' : ')');
 | |
|     }
 | |
|     // account 4x
 | |
|     if ('account' === type && null === name && 'in' === direction) {
 | |
|         return translations.unknown_source + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
|     if ('account' === type && null !== name && 'in' === direction) {
 | |
|         return translations.revenue_account + '"' + name + '"' + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
|     if ('account' === type && null === name && 'out' === direction) {
 | |
|         return translations.unknown_dest + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
|     if ('account' === type && null !== name && 'out' === direction) {
 | |
|         return translations.expense_account + ' "' + name + '"' + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
| 
 | |
|     // budget 2x
 | |
|     if ('budget' === type && null !== name) {
 | |
|         return translations.budget + ' "' + name + '"' + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
|     if ('budget' === type && null === name) {
 | |
|         return translations.unknown_budget + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
|     console.error('Cannot handle: type:"' + type + '", dir: "' + direction + '"');
 | |
| }
 | |
| 
 | |
| function getLabelName(type, name, code) {
 | |
|     // category
 | |
|     if ('category' === type && null !== name) {
 | |
|         return translations.category + ' "' + name + '"' + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
|     if ('category' === type && null === name) {
 | |
|         return translations.unknown_category + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
|     // account
 | |
|     if ('account' === type && null === name) {
 | |
|         return translations.unknown_account + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
|     if ('account' === type && null !== name) {
 | |
|         return name + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
| 
 | |
|     // budget 2x
 | |
|     if ('budget' === type && null !== name) {
 | |
|         return translations.budget + ' "' + name + '"' + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
|     if ('budget' === type && null === name) {
 | |
|         return translations.unknown_budget + (autoConversion ? ' (' + code + ')' : '');
 | |
|     }
 | |
|     console.error('Cannot handle: type:"' + type + '"');
 | |
| }
 | |
| 
 | |
| 
 | |
| export default () => ({
 | |
|     loading: false,
 | |
|     autoConversion: false,
 | |
|     generateOptions() {
 | |
|         let options = getDefaultChartSettings('sankey');
 | |
| 
 | |
|         // reset currencies
 | |
|         currencies = [];
 | |
| 
 | |
|         // variables collected for the sankey chart:
 | |
|         let amounts = {};
 | |
|         let labels = {};
 | |
|         for (let i in transactions) {
 | |
|             if (transactions.hasOwnProperty(i)) {
 | |
|                 let group = transactions[i];
 | |
|                 for (let ii in group.attributes.transactions) {
 | |
|                     if (group.attributes.transactions.hasOwnProperty(ii)) {
 | |
|                         // properties of the transaction, used in the generation of the chart:
 | |
|                         let transaction = group.attributes.transactions[ii];
 | |
|                         let currencyCode = this.autoConversion ? transaction.native_currency_code : transaction.currency_code;
 | |
|                         let amount = this.autoConversion ? parseFloat(transaction.native_amount) : parseFloat(transaction.amount);
 | |
|                         let flowKey;
 | |
| 
 | |
|                         /*
 | |
|                         Two entries in the sankey diagram for deposits:
 | |
|                         1. From the revenue account (source) to a category (in).
 | |
|                         2. From the category (in) to the big inbox.
 | |
|                          */
 | |
|                         if ('deposit' === transaction.type) {
 | |
|                             // nr 1
 | |
|                             let category = getObjectName('category', transaction.category_name, 'in', currencyCode);
 | |
|                             let revenueAccount = getObjectName('account', transaction.source_name, 'in', currencyCode);
 | |
|                             labels[category] = getLabelName('category', transaction.category_name, currencyCode);
 | |
|                             labels[revenueAccount] = getLabelName('account', transaction.source_name, currencyCode);
 | |
|                             flowKey = revenueAccount + '-' + category + '-' + currencyCode;
 | |
|                             if (!amounts.hasOwnProperty(flowKey)) {
 | |
|                                 amounts[flowKey] = {
 | |
|                                     from: revenueAccount,
 | |
|                                     to: category,
 | |
|                                     amount: 0
 | |
|                                 };
 | |
|                             }
 | |
|                             amounts[flowKey].amount += amount;
 | |
| 
 | |
|                             // nr 2
 | |
|                             flowKey = category + '-' + translations.all_money + '-' + currencyCode;
 | |
|                             if (!amounts.hasOwnProperty(flowKey)) {
 | |
|                                 amounts[flowKey] = {
 | |
|                                     from: category,
 | |
|                                     to: translations.all_money + (this.autoConversion ? ' (' + currencyCode + ')' : ''),
 | |
|                                     amount: 0
 | |
|                                 };
 | |
|                             }
 | |
|                             amounts[flowKey].amount += amount;
 | |
|                         }
 | |
|                         /*
 | |
|                         Three entries in the sankey diagram for withdrawals:
 | |
|                         1. From the big box to a budget.
 | |
|                         2. From a budget to a category.
 | |
|                         3. From a category to an expense account.
 | |
|                          */
 | |
|                         if ('withdrawal' === transaction.type) {
 | |
|                             // 1.
 | |
|                             let budget = getObjectName('budget', transaction.budget_name, 'out', currencyCode);
 | |
|                             labels[budget] = getLabelName('budget', transaction.budget_name, currencyCode);
 | |
|                             flowKey = translations.all_money + '-' + budget + '-' + currencyCode;
 | |
| 
 | |
|                             if (!amounts.hasOwnProperty(flowKey)) {
 | |
|                                 amounts[flowKey] = {
 | |
|                                     from: translations.all_money + (this.autoConversion ? ' (' + currencyCode + ')' : ''),
 | |
|                                     to: budget,
 | |
|                                     amount: 0
 | |
|                                 };
 | |
|                             }
 | |
|                             amounts[flowKey].amount += amount;
 | |
| 
 | |
| 
 | |
|                             // 2.
 | |
|                             let category = getObjectName('category', transaction.category_name, 'out', currencyCode);
 | |
|                             labels[category] = getLabelName('category', transaction.category_name, currencyCode);
 | |
|                             flowKey = budget + '-' + category + '-' + currencyCode;
 | |
| 
 | |
|                             if (!amounts.hasOwnProperty(flowKey)) {
 | |
|                                 amounts[flowKey] = {
 | |
|                                     from: budget,
 | |
|                                     to: category,
 | |
|                                     amount: 0
 | |
|                                 };
 | |
|                             }
 | |
|                             amounts[flowKey].amount += amount;
 | |
| 
 | |
|                             // 3.
 | |
|                             let expenseAccount = getObjectName('account', transaction.destination_name, 'out', currencyCode);
 | |
|                             labels[expenseAccount] = getLabelName('account', transaction.destination_name, currencyCode);
 | |
|                             flowKey = category + '-' + expenseAccount + '-' + currencyCode;
 | |
| 
 | |
|                             if (!amounts.hasOwnProperty(flowKey)) {
 | |
|                                 amounts[flowKey] = {
 | |
|                                     from: category,
 | |
|                                     to: expenseAccount,
 | |
|                                     amount: 0
 | |
|                                 };
 | |
|                             }
 | |
|                             amounts[flowKey].amount += amount;
 | |
|                         }
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         let dataSet =
 | |
|             // sankey chart has one data set.
 | |
|             {
 | |
|                 label: 'Firefly III dashboard sankey chart',
 | |
|                 data: [],
 | |
|                 colorFrom: (c) => getColor(c.dataset.data[c.dataIndex] ? c.dataset.data[c.dataIndex].from : ''),
 | |
|                 colorTo: (c) => getColor(c.dataset.data[c.dataIndex] ? c.dataset.data[c.dataIndex].to : ''),
 | |
|                 colorMode: 'gradient', // or 'from' or 'to'
 | |
|                 labels: labels,
 | |
|                 size: 'min', // or 'min' if flow overlap is preferred
 | |
|             };
 | |
|         for (let i in amounts) {
 | |
|             if (amounts.hasOwnProperty(i)) {
 | |
|                 let amount = amounts[i];
 | |
|                 dataSet.data.push({from: amount.from, to: amount.to, flow: amount.amount});
 | |
|             }
 | |
|         }
 | |
|         options.data.datasets.push(dataSet);
 | |
| 
 | |
|         return options;
 | |
|     },
 | |
|     drawChart(options) {
 | |
|         if (null !== chart) {
 | |
|             chart.data.datasets = options.data.datasets;
 | |
|             chart.update();
 | |
|             return;
 | |
|         }
 | |
|         chart = new Chart(document.querySelector("#sankey-chart"), options);
 | |
| 
 | |
|     },
 | |
|     getFreshData() {
 | |
|         const start = new Date(window.store.get('start'));
 | |
|         const end = new Date(window.store.get('end'));
 | |
|         const cacheKey = getCacheKey(SANKEY_CACHE_KEY, start, end);
 | |
| 
 | |
|         const cacheValid = window.store.get('cacheValid');
 | |
|         let cachedData = window.store.get(cacheKey);
 | |
| 
 | |
|         if (cacheValid && typeof cachedData !== 'undefined') {
 | |
|             transactions = cachedData;
 | |
|             this.drawChart(this.generateOptions());
 | |
|             this.loading = false;
 | |
|             return;
 | |
|         }
 | |
| 
 | |
| 
 | |
|         let params = {
 | |
|             start: format(start, 'y-MM-dd'),
 | |
|             end: format(end, 'y-MM-dd'),
 | |
|             type: 'withdrawal,deposit',
 | |
|             page: 1
 | |
|         };
 | |
|         this.downloadTransactions(params);
 | |
|     },
 | |
|     downloadTransactions(params) {
 | |
|         const start = new Date(window.store.get('start'));
 | |
|         const end = new Date(window.store.get('end'));
 | |
|         const cacheKey = getCacheKey(SANKEY_CACHE_KEY, start, end);
 | |
| 
 | |
|         //console.log('Downloading page ' + params.page + '...');
 | |
|         const getter = new Get();
 | |
|         getter.list(params).then((response) => {
 | |
|             transactions = [...transactions, ...response.data.data];
 | |
|             //this.drawChart(this.generateOptions(response.data));
 | |
|             //this.loading = false;
 | |
|             if (parseInt(response.data.meta.pagination.total_pages) > params.page) {
 | |
|                 // continue to next page.
 | |
|                 params.page++;
 | |
|                 this.downloadTransactions(params);
 | |
|                 return;
 | |
|             }
 | |
|             window.store.set(cacheKey, transactions);
 | |
|             this.drawChart(this.generateOptions());
 | |
|             this.loading = false;
 | |
|         });
 | |
|     },
 | |
| 
 | |
|     loadChart() {
 | |
|         if (true === this.loading) {
 | |
|             return;
 | |
|         }
 | |
|         this.loading = true;
 | |
| 
 | |
|         if (0 !== transactions.length) {
 | |
|             this.drawChart(this.generateOptions());
 | |
|             this.loading = false;
 | |
|             return;
 | |
|         }
 | |
|         this.getFreshData();
 | |
|     },
 | |
|     init() {
 | |
|         // console.log('sankey init');
 | |
|         transactions = [];
 | |
|         Promise.all([getVariable('autoConversion', false)]).then((values) => {
 | |
|             this.autoConversion = values[0];
 | |
|             autoConversion = values[0];
 | |
|                 // some translations:
 | |
|                 translations.all_money = i18next.t('firefly.all_money');
 | |
|                 translations.category = i18next.t('firefly.category');
 | |
|                 translations.in = i18next.t('firefly.money_flowing_in');
 | |
|                 translations.out = i18next.t('firefly.money_flowing_out');
 | |
|                 translations.unknown_category = i18next.t('firefly.unknown_category_plain');
 | |
|                 translations.unknown_source = i18next.t('firefly.unknown_source_plain');
 | |
|                 translations.unknown_dest = i18next.t('firefly.unknown_dest_plain');
 | |
|                 translations.unknown_account = i18next.t('firefly.unknown_any_plain');
 | |
|                 translations.unknown_budget = i18next.t('firefly.unknown_budget_plain');
 | |
|                 translations.expense_account = i18next.t('firefly.expense_account');
 | |
|                 translations.revenue_account = i18next.t('firefly.revenue_account');
 | |
|                 translations.budget = i18next.t('firefly.budget');
 | |
| 
 | |
|                 // console.log('sankey after promises');
 | |
|                 afterPromises = true;
 | |
|                 this.loadChart();
 | |
| 
 | |
|         });
 | |
|         window.store.observe('end', () => {
 | |
|             if (!afterPromises) {
 | |
|                 return;
 | |
|             }
 | |
|             // console.log('sankey observe end');
 | |
|             this.transactions = [];
 | |
|             this.loadChart();
 | |
|         });
 | |
|         window.store.observe('autoConversion', (newValue) => {
 | |
|             if (!afterPromises) {
 | |
|                 return;
 | |
|             }
 | |
|             // console.log('sankey observe autoConversion');
 | |
|             this.autoConversion = newValue;
 | |
|             this.loadChart();
 | |
|         });
 | |
|     },
 | |
| 
 | |
| });
 | |
| 
 | |
| 
 |