diff --git a/resources/assets/v2/api/v2/model/subscription/get.js b/resources/assets/v2/api/v2/model/subscription/get.js
new file mode 100644
index 0000000000..af4308c7f4
--- /dev/null
+++ b/resources/assets/v2/api/v2/model/subscription/get.js
@@ -0,0 +1,42 @@
+/*
+ * get.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 .
+ */
+
+
+import {api} from "../../../../boot/axios";
+
+export default class Get {
+
+ /**
+ *
+ * @param params
+ * @returns {Promise>}
+ */
+ get(params) {
+ return api.get('/api/v2/subscriptions', {params: params});
+ }
+
+ paid(params) {
+ return api.get('/api/v2/subscriptions/sum/paid', {params: params});
+ }
+
+ unpaid(params) {
+ return api.get('/api/v2/subscriptions/sum/unpaid', {params: params});
+ }
+}
diff --git a/resources/assets/v2/dashboard.js b/resources/assets/v2/dashboard.js
index 15d26e592e..28c041f87e 100644
--- a/resources/assets/v2/dashboard.js
+++ b/resources/assets/v2/dashboard.js
@@ -25,8 +25,9 @@ import accounts from './pages/dashboard/accounts.js';
import budgets from './pages/dashboard/budgets.js';
import categories from './pages/dashboard/categories.js';
import sankey from './pages/dashboard/sankey.js';
+import subscriptions from './pages/dashboard/subscriptions.js';
-const comps = {dates, boxes, accounts, budgets, categories, sankey};
+const comps = {dates, boxes, accounts, budgets, categories, sankey, subscriptions};
function loadPage(comps) {
Object.keys(comps).forEach(comp => {
diff --git a/resources/assets/v2/pages/dashboard/accounts.js b/resources/assets/v2/pages/dashboard/accounts.js
index d64d9f81a4..73ebeb0f29 100644
--- a/resources/assets/v2/pages/dashboard/accounts.js
+++ b/resources/assets/v2/pages/dashboard/accounts.js
@@ -24,7 +24,8 @@ import {setVariable} from "../../store/set-variable.js";
import Dashboard from "../../api/v2/chart/account/dashboard.js";
import formatMoney from "../../util/format-money.js";
import Get from "../../api/v1/accounts/get.js";
-import Chart from "chart.js/auto";
+//import Chart from "chart.js/auto";
+import {Chart, LineController, LineElement, PointElement, CategoryScale, LinearScale} from "chart.js";
// this is very ugly, but I have no better ideas at the moment to save the currency info
// for each series.
diff --git a/resources/assets/v2/pages/dashboard/sankey.js b/resources/assets/v2/pages/dashboard/sankey.js
index b33ade3c25..974d1857a6 100644
--- a/resources/assets/v2/pages/dashboard/sankey.js
+++ b/resources/assets/v2/pages/dashboard/sankey.js
@@ -27,46 +27,72 @@ Chart.register(SankeyController, Flow);
let currencies = [];
-let chart = null;
+let chart = null;
let transactions = [];
// little helper
-function getObjectName(type, name, direction) {
+function getObjectName(type, name, direction, code) {
// category 4x
if ('category' === type && null !== name && 'in' === direction) {
- return 'Category "' + name + '" (in)';
+ return 'Category "' + name + '" (in ' + code + ')';
}
if ('category' === type && null === name && 'in' === direction) {
- return 'Unknown category (in)';
+ return 'Unknown category (in ' + code + ')';
}
if ('category' === type && null !== name && 'out' === direction) {
- return 'Category "' + name + '" (out)';
+ return 'Category "' + name + '" (out ' + code + ')';
}
if ('category' === type && null === name && 'out' === direction) {
- return 'Unknown category (out)';
+ return 'Unknown category (out ' + code + ')';
}
- // category 4x
+ // account 4x
if ('account' === type && null === name && 'in' === direction) {
- return 'Unknown source account';
+ return 'Unknown source account ' + code + '';
}
if ('account' === type && null !== name && 'in' === direction) {
- return name + ' (in)';
+ return name + ' (in ' + code + ')';
}
if ('account' === type && null === name && 'out' === direction) {
- return 'Unknown destination account';
+ return 'Unknown destination account ' + code + '';
}
if ('account' === type && null !== name && 'out' === direction) {
- return name + ' (out)';
+ return name + ' (out ' + code + ')';
}
- // budget 4x
+ // budget 2x
if ('budget' === type && null !== name && 'out' === direction) {
- return 'Budget "' + name + '" (out)';
+ return 'Budget "' + name + '" (out ' + code + ')';
}
if ('budget' === type && null === name && 'out' === direction) {
- return 'Unknown budget';
+ return 'Unknown budget (' + code + ')';
}
- console.error('Cannot handle: type:"' + type + '",dir: "' + direction + '"');
+ console.error('Cannot handle: type:"' + type + '", dir: "' + direction + '"');
+}
+
+function getLabelName(type, name, code) {
+ // category
+ if ('category' === type && null !== name) {
+ return 'Category "' + name + '" (' + code + ')';
+ }
+ if ('category' === type && null === name) {
+ return 'Unknown category (' + code + ')';
+ }
+ // account
+ if ('account' === type && null === name) {
+ return 'Unknown account (' + code + ')';
+ }
+ if ('account' === type && null !== name) {
+ return name + ' (' + code + ')';
+ }
+
+ // budget 2x
+ if ('budget' === type && null !== name) {
+ return 'Budget "' + name + '" (' + code + ')';
+ }
+ if ('budget' === type && null === name) {
+ return 'Unknown budget (' + code + ')';
+ }
+ console.error('Cannot handle: type:"' + type + '"');
}
export default () => ({
@@ -74,38 +100,38 @@ export default () => ({
autoConversion: false,
sankeyGrouping: 'account',
generateOptions(data) {
- currencies = [];
- console.log('generate options');
let options = getDefaultChartSettings('sankey');
- // temp code for first sankey
- const colors = {
- a: 'red',
- b: 'green',
- c: 'blue',
- d: 'gray'
- };
- const getColor = (key) => colors[key];
- // end of temp code for first sankey
-
+ // reset currencies
+ currencies = [];
+ // variables collected for the sankey chart:
let amounts = {};
- let sort = '10';
- let bigBox = 'TODO All money';
+ let bigBox = 'TODO All money';
+ 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)) {
- let transaction = group.attributes.transactions[ii];
- let amount = this.autoConversion ? parseFloat(transaction.native_amount) : parseFloat(transaction.amount);
- console.log(transaction);
+ // properties of the transaction, used in the generation of the chart:
+ let transaction = group.attributes.transactions[ii];
+ let currencyCode = this.autoConversion ? transaction.native_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) {
- let category = getObjectName('category', transaction.category_name, 'in');
- let revenueAccount = getObjectName('account', transaction.source_name, 'in');
- // first: money flows from a revenue account to a category.
- flowKey = sort + '-' + revenueAccount + '-' + category;
+ // 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,
@@ -115,8 +141,8 @@ export default () => ({
}
amounts[flowKey].amount += amount;
- // second: money flows from category to the big inbox.
- flowKey = sort + '-' + category + '-' + bigBox;
+ // nr 2
+ flowKey = category + '-' + bigBox + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
from: category,
@@ -126,11 +152,17 @@ export default () => ({
}
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) {
- sort = '11';
- // from bigBox to budget
- let budget = getObjectName('budget', transaction.budget_name, 'out');
- flowKey = sort + '-' + bigBox + '-' + budget;
+ // 1.
+ let budget = getObjectName('budget', transaction.budget_name, 'out', currencyCode);
+ labels[budget] = getLabelName('budget', transaction.budget_name, currencyCode);
+ flowKey = bigBox + '-' + budget + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
@@ -142,9 +174,10 @@ export default () => ({
amounts[flowKey].amount += amount;
- // then, it goes from a budget (in) to a category (out)
- let category = getObjectName('category', transaction.category_name, 'out');
- flowKey = sort + '-' + budget + '-' + category;
+ // 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] = {
@@ -155,9 +188,10 @@ export default () => ({
}
amounts[flowKey].amount += amount;
- // if set, from a category (in) to a specific revenue account (out)
- let expenseAccount = getObjectName('account', transaction.destination_name, 'out');
- flowKey = sort + '-' + category + '-' + expenseAccount;
+ // 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] = {
@@ -174,32 +208,32 @@ export default () => ({
}
let dataSet =
- // sankey chart has one data set.
- {
- label: 'My sankey',
- data: [],
- //colorFrom: (c) => getColor(c.dataset.data[c.dataIndex].from),
- //colorTo: (c) => getColor(c.dataset.data[c.dataIndex].to),
- colorMode: 'gradient', // or 'from' or 'to'
- /* optional labels */
- // labels: {
- // a: 'Label A',
- // b: 'Label B',
- // c: 'Label C',
- // d: 'Label D'
- // },
- /* optional priority */
- // priority: {
- // b: 1,
- // d: 0
- // },
- /* optional column overrides */
- // column: {
- // d: 1
- // },
- size: 'max', // or 'min' if flow overlap is preferred
- };
-
+ // sankey chart has one data set.
+ {
+ label: 'My sankey',
+ data: [],
+ //colorFrom: (c) => getColor(c.dataset.data[c.dataIndex].from),
+ //colorTo: (c) => getColor(c.dataset.data[c.dataIndex].to),
+ colorMode: 'gradient', // or 'from' or 'to'
+ labels: labels,
+ /* optional labels */
+ // labels: {
+ // a: 'Label A',
+ // b: 'Label B',
+ // c: 'Label C',
+ // d: 'Label D'
+ // },
+ /* optional priority */
+ // priority: {
+ // b: 1,
+ // d: 0
+ // },
+ /* optional column overrides */
+ // column: {
+ // d: 1
+ // },
+ size: 'max', // or 'min' if flow overlap is preferred
+ };
for (let i in amounts) {
if (amounts.hasOwnProperty(i)) {
let amount = amounts[i];
@@ -229,7 +263,7 @@ export default () => ({
this.downloadTransactions(params);
},
downloadTransactions(params) {
- console.log('Downloading page ' + params.page + '...');
+ //console.log('Downloading page ' + params.page + '...');
const getter = new Get();
getter.get(params).then((response) => {
transactions = [...transactions, ...response.data.data];
@@ -242,8 +276,8 @@ export default () => ({
return;
}
// continue to next step.
- console.log('Final page!');
- console.log(transactions);
+ //console.log('Final page!');
+ //console.log(transactions);
this.drawChart(this.generateOptions());
this.loading = false;
});
diff --git a/resources/assets/v2/pages/dashboard/subscriptions.js b/resources/assets/v2/pages/dashboard/subscriptions.js
new file mode 100644
index 0000000000..13992a1ba5
--- /dev/null
+++ b/resources/assets/v2/pages/dashboard/subscriptions.js
@@ -0,0 +1,154 @@
+/*
+ * 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 .
+ */
+import {getVariable} from "../../store/get-variable.js";
+import Get from "../../api/v2/model/subscription/get.js";
+import Chart from 'chart.js/auto';
+import {getDefaultChartSettings} from "../../support/default-chart-settings.js";
+import formatMoney from "../../util/format-money.js";
+import {format} from "date-fns";
+
+let currencies = [];
+let chart = null;
+let chartData = null;
+
+export default () => ({
+ loading: false,
+ autoConversion: false,
+ loadChart() {
+ if (true === this.loading) {
+ return;
+ }
+ this.loading = true;
+
+ if (null !== chartData) {
+ this.drawChart(this.generateOptions(chartData));
+ this.loading = false;
+ return;
+ }
+ this.getFreshData();
+ },
+ drawChart(options) {
+ if (null !== chart) {
+ chart.data.datasets = options.data.datasets;
+ chart.update();
+ return;
+ }
+ chart = new Chart(document.querySelector("#subscriptions-chart"), options);
+ },
+ getFreshData() {
+ const getter = new Get();
+ let params = {
+ start: format(new Date(window.store.get('start')), 'y-MM-dd'),
+ end: format(new Date(window.store.get('end')), 'y-MM-dd')
+ };
+
+ getter.paid(params).then((response) => {
+ let paidData = response.data;
+ getter.unpaid(params).then((response) => {
+ let unpaidData = response.data;
+ let chartData = {paid: paidData, unpaid: unpaidData};
+ this.drawChart(this.generateOptions(chartData));
+ this.loading = false;
+ });
+ });
+ },
+ generateOptions(data) {
+ let options = getDefaultChartSettings('pie');
+ console.log(data);
+ options.data.labels = ['TODO paid', 'TODO unpaid'];
+ options.data.datasets = [];
+ let collection = {};
+ for (let i in data.paid) {
+ if (data.paid.hasOwnProperty(i)) {
+ let current = data.paid[i];
+ let currencyCode = this.autoConversion ? current.native_code : current.currency_code;
+ let amount = this.autoConversion ? current.native_sum : current.sum;
+ if (!collection.hasOwnProperty(currencyCode)) {
+ collection[currencyCode] = {
+ paid: 0,
+ unpaid: 0,
+ };
+ }
+ // in case of paid, add to "paid":
+ collection[currencyCode].paid += (parseFloat(amount) * -1);
+ }
+ }
+ // unpaid
+ for (let i in data.unpaid) {
+ if (data.unpaid.hasOwnProperty(i)) {
+ let current = data.unpaid[i];
+ let currencyCode = this.autoConversion ? current.native_code : current.currency_code;
+ let amount = this.autoConversion ? current.native_sum : current.sum;
+ if (!collection.hasOwnProperty(currencyCode)) {
+ collection[currencyCode] = {
+ paid: 0,
+ unpaid: 0,
+ };
+ }
+ console.log(current);
+ // in case of paid, add to "paid":
+ collection[currencyCode].unpaid += parseFloat(amount);
+ }
+ }
+ for (let currencyCode in collection) {
+ if (collection.hasOwnProperty(currencyCode)) {
+ let current = collection[currencyCode];
+ options.data.datasets.push(
+ {
+ label: currencyCode,
+ data: [current.paid, current.unpaid],
+ backgroundColor: [
+ 'rgb(54, 162, 235)', // green (paid)
+ 'rgb(255, 99, 132)', // red (unpaid_
+ ],
+ //hoverOffset: 4
+ }
+ )
+ }
+ }
+
+ return options;
+ },
+
+
+ init() {
+ Promise.all([getVariable('autoConversion', false),]).then((values) => {
+ this.autoConversion = values[0];
+ if (false === this.loading) {
+ this.loadChart();
+ }
+ });
+ window.store.observe('end', () => {
+ if (false === this.loading) {
+ this.chartData = null;
+ this.loadChart();
+ }
+ });
+ window.store.observe('autoConversion', (newValue) => {
+ this.autoConversion = newValue;
+ if (false === this.loading) {
+ this.loadChart();
+ }
+ });
+ },
+
+});
+
+
diff --git a/resources/assets/v2/support/default-chart-settings.js b/resources/assets/v2/support/default-chart-settings.js
index 9524905a80..a1c5e06044 100644
--- a/resources/assets/v2/support/default-chart-settings.js
+++ b/resources/assets/v2/support/default-chart-settings.js
@@ -27,6 +27,14 @@ function getDefaultChartSettings(type) {
}
}
}
+ if ('pie' === type) {
+ return {
+ type: 'pie',
+ data: {
+ datasets: [],
+ }
+ }
+ }
if ('column' === type) {
return {
type: 'bar',
diff --git a/resources/views/v2/index.blade.php b/resources/views/v2/index.blade.php
index 8dc3020ba6..21f58b57b1 100644
--- a/resources/views/v2/index.blade.php
+++ b/resources/views/v2/index.blade.php
@@ -161,10 +161,10 @@