Expand layout

This commit is contained in:
James Cole
2023-08-06 18:33:29 +02:00
parent e1915e365a
commit 551408b801
20 changed files with 869 additions and 481 deletions

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* BudgetController.php
* 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/>.
*/
namespace FireflyIII\Api\V2\Controllers\Chart;
use Carbon\Carbon;
use FireflyIII\Api\V2\Controllers\Controller;
use FireflyIII\Api\V2\Request\Generic\DateRequest;
use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Models\Budget;
use FireflyIII\Models\BudgetLimit;
use FireflyIII\Models\TransactionCurrency;
use FireflyIII\Repositories\Administration\Budget\BudgetRepositoryInterface;
use FireflyIII\Repositories\Administration\Budget\OperationsRepositoryInterface;
use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface;
use FireflyIII\Support\Http\Api\ExchangeRateConverter;
use FireflyIII\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
/**
* Class BudgetController
*/
class CategoryController extends Controller
{
public function __construct()
{
parent::__construct();
$this->middleware(
function ($request, $next) {
return $next($request);
}
);
}
/**
* @param DateRequest $request
*
* @return JsonResponse
* @throws FireflyException
*/
public function dashboard(DateRequest $request): JsonResponse {}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,35 +1,35 @@
{
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.ttf": {
"file": "assets/fa-brands-400-20c4a58b.ttf",
"file": "assets/fa-brands-400-003f1154.ttf",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.ttf"
},
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2": {
"file": "assets/fa-brands-400-74833209.woff2",
"file": "assets/fa-brands-400-faae6fc0.woff2",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2"
},
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.ttf": {
"file": "assets/fa-regular-400-528d022d.ttf",
"file": "assets/fa-regular-400-7d81a1a7.ttf",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.ttf"
},
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2": {
"file": "assets/fa-regular-400-8e7e5ea1.woff2",
"file": "assets/fa-regular-400-9169d8be.woff2",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2"
},
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.ttf": {
"file": "assets/fa-solid-900-67a65763.ttf",
"file": "assets/fa-solid-900-cea79b34.ttf",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.ttf"
},
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2": {
"file": "assets/fa-solid-900-7152a693.woff2",
"file": "assets/fa-solid-900-886c8611.woff2",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2"
},
"resources/assets/v2/dashboard.js": {
"file": "assets/dashboard-157793a8.js",
"file": "assets/dashboard-3896887c.js",
"isEntry": true,
"src": "resources/assets/v2/dashboard.js"
},
"resources/assets/v2/sass/app.scss": {
"file": "assets/app-40e01f65.css",
"file": "assets/app-28a195fd.css",
"isEntry": true,
"src": "resources/assets/v2/sass/app.scss"
}

View File

@@ -0,0 +1,30 @@
/*
* overview.js
* Copyright (c) 2022 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 {api} from "../../../../boot/axios";
import {format} from "date-fns";
export default class Dashboard {
dashboard(start, end) {
let startStr = format(start, 'y-MM-dd');
let endStr = format(end, 'y-MM-dd');
return api.get('/api/v2/chart/category/dashboard', {params: {start: startStr, end: endStr}});
}
}

View File

@@ -23,11 +23,13 @@ import dates from './pages/shared/dates.js';
import boxes from './pages/dashboard/boxes.js';
import accounts from './pages/dashboard/accounts.js';
import budgets from './pages/dashboard/budgets.js';
import categories from './pages/dashboard/categories.js';
const comps = {dates, boxes, accounts, budgets};
const comps = {dates, boxes, accounts, budgets, categories};
function loadPage(comps) {
Object.keys(comps).forEach(comp => {
console.log(`Loading ${comp}`);
let data = comps[comp]();
Alpine.data(comp, () => data);
});
@@ -36,11 +38,11 @@ function loadPage(comps) {
// wait for load until bootstrapped event is received.
document.addEventListener('firefly-iii-bootstrapped', () => {
console.log('Loaded through event listener.');
//console.log('Loaded through event listener.');
loadPage(comps);
});
// or is bootstrapped before event is triggered.
if (window.bootstrapped) {
console.log('Loaded through window variable.');
//console.log('Loaded through window variable.');
loadPage(comps);
}

View File

@@ -37,106 +37,119 @@ export default () => ({
accountList: [],
autoConversion: false,
chart: null,
chartData: null,
chartOptions: null,
switchAutoConversion() {
this.autoConversion = !this.autoConversion;
setVariable('autoConversion', this.autoConversion);
this.loadChart();
},
loadChart() {
console.log('loadChart.');
if (true === this.loading) {
console.log('loadChart CANCELLED');
return;
}
console.log('loadChart continues');
// load chart data
this.loading = true;
getFreshData() {
const dashboard = new Dashboard();
dashboard.dashboard(new Date(window.store.get('start')), new Date(window.store.get('end')), null).then((response) => {
// chart options (may need to be centralized later on)
window.currencies = [];
let options = {
legend: {show: false},
chart: {
height: 400,
toolbar: {tools: {zoom: false, download: false, pan: false}},
type: 'line'
}, series: [],
settings: [],
xaxis: {
categories: [],
labels: {
formatter: function (value) {
if (undefined === value) {
return '';
}
const date = new Date(value);
if (date instanceof Date && !isNaN(date)) {
return formatLocal(date, 'PP');
}
console.error('Could not parse "' + value + '", return "".');
return ':(';
}
}
}, yaxis: {
labels: {
formatter: function (value, index) {
if (undefined === value) {
return value;
}
if (undefined === index) {
return value;
}
if (typeof index === 'object') {
index = index.seriesIndex;
}
//console.log(index);
let currencyCode = window.currencies[index] ?? 'EUR';
return formatMoney(value, currencyCode);
}
}
},
};
// render data:
for (let i = 0; i < response.data.length; i++) {
if (response.data.hasOwnProperty(i)) {
let current = response.data[i];
let entry = [];
let collection = [];
// use the "native" currency code and use the "native_entries" as array
if (this.autoConversion) {
window.currencies.push(current.native_code);
collection = current.native_entries;
}
if (!this.autoConversion) {
window.currencies.push(current.currency_code);
collection = current.entries;
}
for (const [ii, value] of Object.entries(collection)) {
entry.push({x: format(new Date(ii), 'yyyy-MM-dd'), y: parseFloat(value)});
}
options.series.push({name: current.label, data: entry});
}
}
if (null !== this.chart) {
// chart already in place, refresh:
this.chart.updateOptions(options);
}
if (null === this.chart) {
this.chart = new ApexCharts(document.querySelector("#account-chart"), options);
this.chart.render();
}
this.loading = false;
this.chartData = response.data;
this.generateOptions(this.chartData);
this.drawChart();
});
}, loadAccounts() {
console.log('loadAccounts');
if (true === this.loadingAccounts) {
console.log('loadAccounts CANCELLED');
},
generateOptions(data) {
window.currencies = [];
let options = {
legend: {show: false},
chart: {
height: 400,
type: 'line'
},
series: [],
settings: [],
xaxis: {
categories: [],
labels: {
formatter: function (value) {
if (undefined === value) {
return '';
}
const date = new Date(value);
if (date instanceof Date && !isNaN(date)) {
return formatLocal(date, 'PP');
}
console.error('Could not parse "' + value + '", return "".');
return ':(';
}
}
},
yaxis: {
labels: {
formatter: function (value, index) {
if (undefined === value) {
return value;
}
if (undefined === index) {
return value;
}
if (typeof index === 'object') {
index = index.seriesIndex;
}
//console.log(index);
let currencyCode = window.currencies[index] ?? 'EUR';
return formatMoney(value, currencyCode);
}
}
},
};
// render data:
for (let i = 0; i < data.length; i++) {
if (data.hasOwnProperty(i)) {
let current = data[i];
let entry = [];
let collection = [];
// use the "native" currency code and use the "native_entries" as array
if (this.autoConversion) {
window.currencies.push(current.native_code);
collection = current.native_entries;
}
if (!this.autoConversion) {
window.currencies.push(current.currency_code);
collection = current.entries;
}
for (const [ii, value] of Object.entries(collection)) {
entry.push({x: format(new Date(ii), 'yyyy-MM-dd'), y: parseFloat(value)});
}
options.series.push({name: current.label, data: entry});
}
}
this.chartOptions = options;
},
loadChart() {
if (true === this.loading) {
return;
}
this.loading = true;
if (null === this.chartData) {
this.getFreshData();
}
if (null !== this.chartData) {
this.generateOptions(this.chartData);
this.drawChart();
}
this.loading = false;
},
drawChart() {
if (null !== this.chart) {
// chart already in place, refresh:
this.chart.updateOptions(this.chartOptions);
}
if (null === this.chart) {
this.chart = new ApexCharts(document.querySelector("#account-chart"), this.chartOptions);
this.chart.render();
}
},
loadAccounts() {
if (true === this.loadingAccounts) {
return;
}
console.log('loadAccounts continues');
this.loadingAccounts = true;
const max = 10;
let totalAccounts = 0;
@@ -144,7 +157,6 @@ export default () => ({
let accounts = [];
Promise.all([getVariable('frontpageAccounts'),]).then((values) => {
totalAccounts = values[0].length;
console.log('total accounts is ' + totalAccounts);
for (let i in values[0]) {
let account = values[0];
if (account.hasOwnProperty(i)) {
@@ -186,7 +198,6 @@ export default () => ({
if (count === totalAccounts) {
this.accountList = accounts;
}
console.log('Count is now ' + count);
});
});
}
@@ -196,16 +207,14 @@ export default () => ({
},
init() {
console.log('init accounts');
Promise.all([getVariable('viewRange', '1M'), getVariable('autoConversion', false),]).then((values) => {
console.log('from promise');
this.autoConversion = values[1];
// console.log(values[1]);
this.loadChart();
this.loadAccounts();
});
window.store.observe('end', () => {
console.log('from observe end');
this.chartData = null;
this.loadChart();
this.loadAccounts();
});

View File

@@ -31,149 +31,164 @@ export default () => ({
netBox: {net: []},
autoConversion: false,
loading: false,
loadBoxes() {
if (this.loading) {
return;
}
this.loading = true;
boxData: null,
boxOptions: null,
getFreshData() {
// get stuff
let getter = new Summary();
let start = new Date(window.store.get('start'));
let end = new Date(window.store.get('end'));
getter.get(format(start, 'yyyy-MM-dd'), format(end, 'yyyy-MM-dd'), null).then((response) => {
// reset boxes:
this.balanceBox = {amounts: [], subtitles: []};
this.billBox = {paid: [], unpaid: []};
this.leftBox = {left: [], perDay: []};
this.netBox = {net: []};
let subtitles = {};
// process new content:
for (const i in response.data) {
if (response.data.hasOwnProperty(i)) {
const current = response.data[i];
let key = current.key;
// native (auto conversion):
if (this.autoConversion) {
if (key.startsWith('balance-in-native')) {
this.balanceBox.amounts.push(formatMoney(current.value, current.currency_code));
// prep subtitles (for later)
if (!subtitles.hasOwnProperty(current.currency_code)) {
subtitles[current.currency_code] = '';
}
continue;
}
// spent info is used in subtitle:
if (key.startsWith('spent-in-native')) {
// prep subtitles (for later)
if (!subtitles.hasOwnProperty(current.currency_code)) {
subtitles[current.currency_code] = '';
}
// append the amount spent.
subtitles[current.currency_code] =
subtitles[current.currency_code] +
formatMoney(current.value, current.currency_code);
continue;
}
// earned info is used in subtitle:
if (key.startsWith('earned-in-native')) {
// prep subtitles (for later)
if (!subtitles.hasOwnProperty(current.currency_code)) {
subtitles[current.currency_code] = '';
}
// prepend the amount earned.
subtitles[current.currency_code] =
formatMoney(current.value, current.currency_code) + ' + ' +
subtitles[current.currency_code];
continue;
}
if (key.startsWith('bills-unpaid-in-native')) {
this.billBox.unpaid.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('bills-paid-in-native')) {
this.billBox.paid.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('left-to-spend-in-native')) {
this.leftBox.left.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('left-per-day-to-spend-in-native')) { // per day
this.leftBox.perDay.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('net-worth-in-native')) {
this.netBox.net.push(formatMoney(current.value, current.currency_code));
continue;
}
}
// not native
if (!this.autoConversion && !key.endsWith('native')) {
if (key.startsWith('balance-in-')) {
this.balanceBox.amounts.push(formatMoney(current.value, current.currency_code));
continue;
}
// spent info is used in subtitle:
if (key.startsWith('spent-in-')) {
// prep subtitles (for later)
if (!subtitles.hasOwnProperty(current.currency_code)) {
subtitles[current.currency_code] = '';
}
// append the amount spent.
subtitles[current.currency_code] =
subtitles[current.currency_code] +
formatMoney(current.value, current.currency_code);
continue;
}
// earned info is used in subtitle:
if (key.startsWith('earned-in-')) {
// prep subtitles (for later)
if (!subtitles.hasOwnProperty(current.currency_code)) {
subtitles[current.currency_code] = '';
}
// prepend the amount earned.
subtitles[current.currency_code] =
formatMoney(current.value, current.currency_code) + ' + ' +
subtitles[current.currency_code];
continue;
}
if (key.startsWith('bills-unpaid-in-')) {
this.billBox.unpaid.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('bills-paid-in-')) {
this.billBox.paid.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('left-to-spend-in-')) {
this.leftBox.left.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('left-per-day-to-spend-in-')) {
this.leftBox.perDay.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('net-worth-in-')) {
this.netBox.net.push(formatMoney(current.value, current.currency_code));
}
}
}
}
for (let i in subtitles) {
if (subtitles.hasOwnProperty(i)) {
this.balanceBox.subtitles.push(subtitles[i]);
}
}
this.loading = false;
this.boxData = response.data;
this.generateOptions(this.boxData);
//this.drawChart();
});
},
generateOptions(data) {
this.balanceBox = {amounts: [], subtitles: []};
this.billBox = {paid: [], unpaid: []};
this.leftBox = {left: [], perDay: []};
this.netBox = {net: []};
let subtitles = {};
// process new content:
for (const i in data) {
if (data.hasOwnProperty(i)) {
const current = data[i];
let key = current.key;
// native (auto conversion):
if (this.autoConversion) {
if (key.startsWith('balance-in-native')) {
this.balanceBox.amounts.push(formatMoney(current.value, current.currency_code));
// prep subtitles (for later)
if (!subtitles.hasOwnProperty(current.currency_code)) {
subtitles[current.currency_code] = '';
}
continue;
}
// spent info is used in subtitle:
if (key.startsWith('spent-in-native')) {
// prep subtitles (for later)
if (!subtitles.hasOwnProperty(current.currency_code)) {
subtitles[current.currency_code] = '';
}
// append the amount spent.
subtitles[current.currency_code] =
subtitles[current.currency_code] +
formatMoney(current.value, current.currency_code);
continue;
}
// earned info is used in subtitle:
if (key.startsWith('earned-in-native')) {
// prep subtitles (for later)
if (!subtitles.hasOwnProperty(current.currency_code)) {
subtitles[current.currency_code] = '';
}
// prepend the amount earned.
subtitles[current.currency_code] =
formatMoney(current.value, current.currency_code) + ' + ' +
subtitles[current.currency_code];
continue;
}
if (key.startsWith('bills-unpaid-in-native')) {
this.billBox.unpaid.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('bills-paid-in-native')) {
this.billBox.paid.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('left-to-spend-in-native')) {
this.leftBox.left.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('left-per-day-to-spend-in-native')) { // per day
this.leftBox.perDay.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('net-worth-in-native')) {
this.netBox.net.push(formatMoney(current.value, current.currency_code));
continue;
}
}
// not native
if (!this.autoConversion && !key.endsWith('native')) {
if (key.startsWith('balance-in-')) {
this.balanceBox.amounts.push(formatMoney(current.value, current.currency_code));
continue;
}
// spent info is used in subtitle:
if (key.startsWith('spent-in-')) {
// prep subtitles (for later)
if (!subtitles.hasOwnProperty(current.currency_code)) {
subtitles[current.currency_code] = '';
}
// append the amount spent.
subtitles[current.currency_code] =
subtitles[current.currency_code] +
formatMoney(current.value, current.currency_code);
continue;
}
// earned info is used in subtitle:
if (key.startsWith('earned-in-')) {
// prep subtitles (for later)
if (!subtitles.hasOwnProperty(current.currency_code)) {
subtitles[current.currency_code] = '';
}
// prepend the amount earned.
subtitles[current.currency_code] =
formatMoney(current.value, current.currency_code) + ' + ' +
subtitles[current.currency_code];
continue;
}
if (key.startsWith('bills-unpaid-in-')) {
this.billBox.unpaid.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('bills-paid-in-')) {
this.billBox.paid.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('left-to-spend-in-')) {
this.leftBox.left.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('left-per-day-to-spend-in-')) {
this.leftBox.perDay.push(formatMoney(current.value, current.currency_code));
continue;
}
if (key.startsWith('net-worth-in-')) {
this.netBox.net.push(formatMoney(current.value, current.currency_code));
}
}
}
}
for (let i in subtitles) {
if (subtitles.hasOwnProperty(i)) {
this.balanceBox.subtitles.push(subtitles[i]);
}
}
},
loadBoxes() {
if (true === this.loading) {
return;
}
this.loading = true;
if (null === this.boxData) {
this.getFreshData();
}
if (null !== this.boxData) {
this.generateOptions(this.boxData);
//this.drawChart();
}
this.loading = false;
},
// Getter
@@ -183,6 +198,7 @@ export default () => ({
this.loadBoxes();
});
window.store.observe('end', () => {
this.boxData = null;
this.loadBoxes();
});
window.store.observe('autoConversion', (newValue) => {

View File

@@ -24,158 +24,176 @@ import formatMoney from "../../util/format-money.js";
window.budgetCurrencies = [];
export default () => ({
loadingChart: false,
loading: false,
chart: null,
autoConversion: false,
chartData: null,
chartOptions: null,
loadChart() {
if (this.loadingChart) {
if (true === this.loading) {
return;
}
// load chart data
this.loadingChart = true;
window.budgetCurrencies = [];
this.loading = true;
if (null === this.chartData) {
this.getFreshData();
}
if (null !== this.chartData) {
this.generateOptions(this.chartData);
this.drawChart();
}
this.loading = false;
},
drawChart() {
if (null !== this.chart) {
// chart already in place, refresh:
this.chart.updateOptions(this.chartOptions);
}
if (null === this.chart) {
this.chart = new ApexCharts(document.querySelector("#budget-chart"), this.chartOptions);
this.chart.render();
}
},
getFreshData() {
const dashboard = new Dashboard();
dashboard.dashboard(new Date(window.store.get('start')), new Date(window.store.get('end')), null).then((response) => {
let options = {
legend: {show: false},
series: [{
name: 'Spent',
data: []
}, {
name: 'Left',
data: []
}, {
name: 'Overspent',
data: []
}],
chart: {
type: 'bar',
height: 400,
stacked: true,
toolbar: {tools: {zoom: false, download: false, pan: false}},
zoom: {
enabled: true
this.chartData = response.data;
this.generateOptions(this.chartData);
this.drawChart();
});
},
generateOptions(data) {
window.budgetCurrencies = [];
let options = {
legend: {show: false},
series: [{
name: 'Spent',
data: []
}, {
name: 'Left',
data: []
}, {
name: 'Overspent',
data: []
}],
chart: {
type: 'bar',
height: 400,
stacked: true,
toolbar: {tools: {zoom: false, download: false, pan: false}},
zoom: {
enabled: true
}
},
responsive: [{
breakpoint: 480,
options: {
legend: {
position: 'bottom',
offsetX: -10,
offsetY: 0
}
},
responsive: [{
breakpoint: 480,
options: {
legend: {
position: 'bottom',
offsetX: -10,
offsetY: 0
}
}
}],
plotOptions: {
bar: {
horizontal: false,
borderRadius: 10,
dataLabels: {
total: {
enabled: true,
style: {
fontSize: '13px',
fontWeight: 900
},
formatter: function (val, opt) {
let index = 0;
if (typeof opt === 'object') {
index = opt.dataPointIndex; // this is the "category name + currency" index
}
let currencyCode = window.budgetCurrencies[index] ?? 'EUR';
return formatMoney(val, currencyCode);
}
}],
plotOptions: {
bar: {
horizontal: false,
borderRadius: 10,
dataLabels: {
total: {
enabled: true,
// style: {
// fontSize: '13px',
// fontWeight: 900
// },
formatter: function (val, opt) {
let index = 0;
if (typeof opt === 'object') {
index = opt.dataPointIndex; // this is the "category name + currency" index
}
let currencyCode = window.budgetCurrencies[index] ?? 'EUR';
return formatMoney(val, currencyCode);
}
}
},
},
yaxis: {
labels: {
formatter: function (value, index) {
if (undefined === value) {
return value;
}
if (undefined === index) {
return value;
}
if (typeof index === 'object') {
index = index.dataPointIndex; // this is the "category name + currency" index
}
let currencyCode = window.budgetCurrencies[index] ?? 'EUR';
return formatMoney(value, currencyCode);
}
}
},
xaxis: {
categories: []
},
fill: {
opacity: 0.8
},
dataLabels: {
formatter: function (val, opt) {
let index = 0;
if (typeof opt === 'object') {
index = opt.dataPointIndex; // this is the "category name + currency" index
},
yaxis: {
labels: {
formatter: function (value, index) {
if (undefined === value) {
return value;
}
if (undefined === index) {
return value;
}
if (typeof index === 'object') {
index = index.dataPointIndex; // this is the "category name + currency" index
}
let currencyCode = window.budgetCurrencies[index] ?? 'EUR';
return formatMoney(val, currencyCode);
},
}
};
for (const i in response.data) {
if (response.data.hasOwnProperty(i)) {
let current = response.data[i];
// convert to EUR yes no?
let label = current.label + ' (' + current.currency_code + ')';
options.xaxis.categories.push(label);
if (this.autoConversion) {
window.budgetCurrencies.push(current.native_code);
// series 0: spent
options.series[0].data.push(parseFloat(current.native_entries.spent) * -1);
// series 1: left
options.series[1].data.push(parseFloat(current.native_entries.left));
// series 2: overspent
options.series[2].data.push(parseFloat(current.native_entries.overspent));
return formatMoney(value, currencyCode);
}
if (!this.autoConversion) {
window.budgetCurrencies.push(current.currency_code);
// series 0: spent
options.series[0].data.push(parseFloat(current.entries.spent) * -1);
// series 1: left
options.series[1].data.push(parseFloat(current.entries.left));
// series 2: overspent
options.series[2].data.push(parseFloat(current.entries.overspent));
}
}
},
xaxis: {
categories: []
},
fill: {
opacity: 0.8
},
dataLabels: {
formatter: function (val, opt) {
let index = 0;
if (typeof opt === 'object') {
index = opt.dataPointIndex; // this is the "category name + currency" index
}
let currencyCode = window.budgetCurrencies[index] ?? 'EUR';
return formatMoney(val, currencyCode);
},
}
};
if (null !== this.chart) {
// chart already in place, refresh:
this.chart.updateOptions(options);
for (const i in data) {
if (data.hasOwnProperty(i)) {
let current = data[i];
// convert to EUR yes no?
let label = current.label + ' (' + current.currency_code + ')';
options.xaxis.categories.push(label);
if (this.autoConversion) {
window.budgetCurrencies.push(current.native_code);
// series 0: spent
options.series[0].data.push(parseFloat(current.native_entries.spent) * -1);
// series 1: left
options.series[1].data.push(parseFloat(current.native_entries.left));
// series 2: overspent
options.series[2].data.push(parseFloat(current.native_entries.overspent));
}
if (!this.autoConversion) {
window.budgetCurrencies.push(current.currency_code);
// series 0: spent
options.series[0].data.push(parseFloat(current.entries.spent) * -1);
// series 1: left
options.series[1].data.push(parseFloat(current.entries.left));
// series 2: overspent
options.series[2].data.push(parseFloat(current.entries.overspent));
}
}
if (null === this.chart) {
this.chart = new ApexCharts(document.querySelector("#budget-chart"), options);
this.chart.render();
}
this.loadingChart = false;
});
}
this.chartOptions = options;
},
init() {
Promise.all([getVariable('autoConversion', false),]).then((values) => {
this.autoConversion = values[0];
this.loadChart();
});
// todo the charts don't need to reload from server if the autoConversion value changes.
window.store.observe('end', () => {
this.chartData = null;
this.loadChart();
});
window.store.observe('autoConversion', (newValue) => {

View File

@@ -0,0 +1,205 @@
/*
* 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 Dashboard from "../../api/v2/chart/category/dashboard.js";
import ApexCharts from "apexcharts";
import formatMoney from "../../util/format-money.js";
window.categoryCurrencies = [];
export default () => ({
loading: false,
chart: null,
autoConversion: false,
chartData: null,
chartOptions: null,
generateOptions(data) {
window.categoryCurrencies = [];
let options = {
series: [],
chart: {
type: 'bar',
height: 350
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
endingShape: 'rounded'
},
},
dataLabels: {
enabled: false
},
stroke: {
show: true,
width: 2,
colors: ['transparent']
},
xaxis: {
categories: [],
},
yaxis: {},
fill: {
opacity: 1
},
tooltip: {
y: {
formatter: function (value, index) {
if (undefined === value) {
return value;
}
if (undefined === index) {
return value;
}
if (typeof index === 'object') {
index = index.seriesIndex; // this is the currency index.
}
let currencyCode = window.categoryCurrencies[index] ?? 'EUR';
return formatMoney(value, currencyCode);
}
}
}
};
// first, collect all currencies and use them as series.
let series = {};
for (const i in data) {
if (data.hasOwnProperty(i)) {
let current = data[i];
let code = current.currency_code;
// only use native code when doing auto conversion.
if (this.autoConversion) {
code = current.native_code;
}
if (!series.hasOwnProperty(code)) {
series[code] = {
name: code,
data: {},
};
window.categoryCurrencies.push(code);
}
}
}
// loop data again to add amounts.
for (const i in data) {
if (data.hasOwnProperty(i)) {
let current = data[i];
let code = current.currency_code;
if (this.autoConversion) {
code = current.native_code;
}
// loop series, add 0 if not present or add actual amount.
for (const ii in series) {
if (series.hasOwnProperty(ii)) {
let amount = 0.0;
if (code === ii) {
// this series' currency matches this column's currency.
amount = parseFloat(current.amount);
if (this.autoConversion) {
amount = parseFloat(current.native_amount);
}
}
if (series[ii].data.hasOwnProperty(current.label)) {
// there is a value for this particular currency. The amount from this column will be added.
// (even if this column isn't recorded in this currency and a new filler value is written)
// this is so currency conversion works.
series[ii].data[current.label] = series[ii].data[current.label] + amount;
}
if (!series[ii].data.hasOwnProperty(current.label)) {
// this column's amount is not yet set in this series.
series[ii].data[current.label] = amount;
}
}
}
// add label to x-axis, not unimportant.
if (!options.xaxis.categories.includes(current.label)) {
options.xaxis.categories.push(current.label);
}
}
}
// loop the series and create Apex-compatible data sets.
for (const i in series) {
let current = {
name: i,
data: [],
}
for (const ii in series[i].data) {
current.data.push(series[i].data[ii]);
}
options.series.push(current);
}
this.chartOptions = options;
},
drawChart() {
if (null !== this.chart) {
// chart already in place, refresh:
this.chart.updateOptions(this.chartOptions);
}
if (null === this.chart) {
this.chart = new ApexCharts(document.querySelector("#category-chart"), this.chartOptions);
this.chart.render();
}
this.loading = false;
},
getFreshData() {
const dashboard = new Dashboard();
dashboard.dashboard(new Date(window.store.get('start')), new Date(window.store.get('end')), null).then((response) => {
this.chartData = response.data;
this.generateOptions(this.chartData);
this.drawChart();
});
},
loadChart() {
if (true === this.loading) {
return;
}
this.loading = true;
if (null === this.chartData) {
this.getFreshData();
}
if (null !== this.chartData) {
this.generateOptions(this.chartData);
this.drawChart();
}
this.loading = false;
},
init() {
Promise.all([getVariable('autoConversion', false),]).then((values) => {
this.autoConversion = values[0];
this.loadChart();
});
window.store.observe('end', () => {
this.chartData = null;
this.loadChart();
});
window.store.observe('autoConversion', (newValue) => {
this.autoConversion = newValue;
this.loadChart();
});
},
});

View File

@@ -9,102 +9,100 @@
<div class="container-fluid">
@include('partials.dashboard.boxes')
<!-- row with account data -->
<div>
<div class="row" x-data="accounts">
<div class="col-xl-8 col-lg-12 col-sm-12 col-xs-12">
<div class="row mb-2">
<div class="col">
<div class="row mb-2" x-data="accounts">
<div class="col-xl-8 col-lg-12 col-sm-12 col-xs-12">
<div class="row mb-2">
<div class="col">
<div class="card">
<div class="card-header">
<h3 class="card-title"><a href="{{ route('accounts.index',['asset']) }}"
title="{{ __('firefly.yourAccounts') }}">{{ __('firefly.yourAccounts') }}</a>
</h3>
</div>
<div class="card-body">
<div id="account-chart"></div>
<p class="text-end">
<template x-if="autoConversion">
<button type="button" @click="switchAutoConversion"
class="btn btn-outline-info btm-sm">
<div class="card">
<div class="card-header">
<h3 class="card-title"><a href="{{ route('accounts.index',['asset']) }}"
title="{{ __('firefly.yourAccounts') }}">{{ __('firefly.yourAccounts') }}</a>
</h3>
</div>
<div class="card-body">
<div id="account-chart"></div>
<p class="text-end">
<template x-if="autoConversion">
<button type="button" @click="switchAutoConversion"
class="btn btn-outline-info btm-sm">
<span
class="fa-solid fa-comments-dollar"></span> {{ __('firefly.disable_auto_convert') }}
</button>
</template>
<template x-if="!autoConversion">
<button type="button" @click="switchAutoConversion"
class="btn btn-outline-info btm-sm">
</button>
</template>
<template x-if="!autoConversion">
<button type="button" @click="switchAutoConversion"
class="btn btn-outline-info btm-sm">
<span
class="fa-solid fa-comments-dollar"></span> {{ __('firefly.enable_auto_convert') }}
</button>
</template>
</p>
</div>
</button>
</template>
</p>
</div>
</div>
</div>
<div class="row mb-2" x-data="budgets">
<div class="col">
<div class="card">
<div class="card-header">
<h3 class="card-title"><a href="{{ route('budgets.index') }}"
title="{{ __('firefly.go_to_budgets') }}">{{ __('firefly.go_to_budgets') }}</a>
</h3>
</div>
<div class="card-body">
<div id="budget-chart"></div>
</div>
</div>
</div>
</div>
<div class="row mb-2" x-data="budgets">
<div class="col">
<div class="card">
<div class="card-header">
<h3 class="card-title"><a href="{{ route('budgets.index') }}"
title="{{ __('firefly.go_to_budgets') }}">{{ __('firefly.budgetsAndSpending') }}</a>
</h3>
</div>
<div class="card-body">
<div id="budget-chart"></div>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col">
<div class="card">
<div class="card-header">
<h3 class="card-title"><a href="{{ route('accounts.index',['asset']) }}"
title="{{ __('firefly.yourAccounts') }}">cat</a>
</h3>
</div>
<div class="card-body">
<div id="category-chart"></div>
</div>
</div>
</div>
</div>
<div class="row" x-data="categories">
<div class="col">
<div class="card">
<div class="card-header">
<h3 class="card-title"><a href="{{ route('categories.index') }}"
title="{{ __('firefly.yourAccounts') }}">{{ __('firefly.categories') }}</a>
</h3>
</div>
<div class="card-body">
<div id="category-chart"></div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-lg-12 col-sm-12 col-xs-12">
<div class="row">
<template x-for="account in accountList">
<div class="col-12 mb-2" x-model="account">
<div class="card">
<div class="card-header">
<h3 class="card-title">
<a :href="'{{ route('accounts.show','') }}/' + account.id"
x-text="account.name"></a>
</div>
<div class="col-xl-4 col-lg-12 col-sm-12 col-xs-12">
<div class="row">
<template x-for="account in accountList">
<div class="col-12 mb-2" x-model="account">
<div class="card">
<div class="card-header">
<h3 class="card-title">
<a :href="'{{ route('accounts.show','') }}/' + account.id"
x-text="account.name"></a>
<span class="small text-muted">(<span
x-text="account.balance"></span>)</span>
</h3>
</div>
<div class="card-body p-0">
<p class="text-center small" x-show="account.groups.length < 1">
TODO No transactions
<span class="small text-muted">(<span
x-text="account.balance"></span>)</span>
</h3>
</div>
<div class="card-body p-0">
<p class="text-center small" x-show="account.groups.length < 1">
TODO No transactions
</p>
<table class="table table-sm" x-show="account.groups.length > 0">
<tbody>
<template x-for="group in account.groups">
<tr>
<td>
<template x-if="group.title">
</p>
<table class="table table-sm" x-show="account.groups.length > 0">
<tbody>
<template x-for="group in account.groups">
<tr>
<td>
<template x-if="group.title">
<span><a
:href="'{{route('transactions.show', '') }}/' + group.id"
x-text="group.title"></a><br/></span>
</template>
<template x-for="transaction in group.transactions">
</template>
<template x-for="transaction in group.transactions">
<span>
<template x-if="group.title">
<span>-
@@ -119,32 +117,74 @@
</span>
</template>
</span>
</template>
</td>
<td style="width:30%;" class="text-end">
<template x-if="group.title">
<span><br/></span>
</template>
<template x-for="transaction in group.transactions">
</template>
</td>
<td style="width:30%;" class="text-end">
<template x-if="group.title">
<span><br/></span>
</template>
<template x-for="transaction in group.transactions">
<span>
<span x-text="transaction.amount"></span><br>
</span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col">
<div class="card">
<div class="card-header">
<h3 class="card-title"><a href="#" title="Something">Expense accounts</a></h3>
</div>
<div class="card-body">
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">
<h3 class="card-title"><a href="#" title="Something">Bills</a></h3>
</div>
<div class="card-body">
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-header">
<h3 class="card-title"><a href="#" title="Something">Spaarpotjes</a></h3>
</div>
<div class="card-body">
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-header">
<h3 class="card-title"><a href="#" title="Something">Revenue</a></h3>
</div>
<div class="card-body">
</div>
</div>
</div>
</div>
<!-- row with budget chart -->
</div>