Expand account index overview.

This commit is contained in:
James Cole
2024-04-28 09:54:28 +02:00
parent 56c9026299
commit c1c0afa40b
22 changed files with 502 additions and 189 deletions

View File

@@ -157,6 +157,9 @@ class Controller extends BaseController
{
$manager = new Manager();
$baseUrl = request()->getSchemeAndHttpHost().'/api/v2';
// TODO add stuff to path?
$manager->setSerializer(new JsonApiSerializer($baseUrl));
$objects = $paginator->getCollection();

View File

@@ -33,7 +33,7 @@ use Illuminate\Pagination\LengthAwarePaginator;
class IndexController extends Controller
{
public const string RESOURCE_KEY = 'accounts';
public const string RESOURCE_KEY = 'accounts';
private AccountRepositoryInterface $repository;
protected array $acceptedRoles = [UserRoleEnum::READ_ONLY, UserRoleEnum::MANAGE_TRANSACTIONS];
@@ -48,7 +48,7 @@ class IndexController extends Controller
function ($request, $next) {
$this->repository = app(AccountRepositoryInterface::class);
// new way of user group validation
$userGroup = $this->validateUserGroup($request);
$userGroup = $this->validateUserGroup($request);
$this->repository->setUserGroup($userGroup);
return $next($request);
@@ -57,26 +57,28 @@ class IndexController extends Controller
}
/**
* TODO the sort instructions need proper repeatable documentation.
* TODO see autocomplete/account controller for list.
*/
public function index(IndexRequest $request): JsonResponse
{
$this->repository->resetAccountOrder();
$types = $request->getAccountTypes();
$instructions = $request->getSortInstructions('accounts');
$accounts = $this->repository->getAccountsByType($types, $instructions);
$pageSize = $this->parameters->get('limit');
$count = $accounts->count();
$accounts = $accounts->slice(($this->parameters->get('page') - 1) * $pageSize, $pageSize);
$paginator = new LengthAwarePaginator($accounts, $count, $pageSize, $this->parameters->get('page'));
$transformer = new AccountTransformer();
$types = $request->getAccountTypes();
$sorting = $request->getSortInstructions('accounts');
$filters = $request->getFilterInstructions('accounts');
$accounts = $this->repository->getAccountsByType($types, $sorting, $filters);
$pageSize = $this->parameters->get('limit');
$count = $accounts->count();
$accounts = $accounts->slice(($this->parameters->get('page') - 1) * $pageSize, $pageSize);
$paginator = new LengthAwarePaginator($accounts, $count, $pageSize, $this->parameters->get('page'));
$transformer = new AccountTransformer();
$this->parameters->set('sort', $instructions);
$this->parameters->set('sort', $sorting);
$this->parameters->set('filters', $filters);
$transformer->setParameters($this->parameters); // give params to transformer
return response()
->json($this->jsonApiList('accounts', $paginator, $transformer))
->header('Content-Type', self::CONTENT_TYPE)
;
->header('Content-Type', self::CONTENT_TYPE);
}
}

View File

@@ -27,6 +27,7 @@ use Carbon\Carbon;
use FireflyIII\Support\Http\Api\AccountFilter;
use FireflyIII\Support\Request\ChecksLogin;
use FireflyIII\Support\Request\ConvertsDataTypes;
use FireflyIII\Support\Request\GetFilterInstructions;
use FireflyIII\Support\Request\GetSortInstructions;
use Illuminate\Foundation\Http\FormRequest;
@@ -41,6 +42,8 @@ class IndexRequest extends FormRequest
use ChecksLogin;
use ConvertsDataTypes;
use GetSortInstructions;
use GetFilterInstructions;
public function getAccountTypes(): array
{

View File

@@ -240,7 +240,7 @@ class AccountRepository implements AccountRepositoryInterface
}
}
public function getAccountsByType(array $types, ?array $sort = []): Collection
public function getAccountsByType(array $types, ?array $sort = [], ?array $filters = []): Collection
{
$sortable = ['name', 'active']; // TODO yes this is a duplicate array.
$res = array_intersect([AccountType::ASSET, AccountType::MORTGAGE, AccountType::LOAN, AccountType::DEBT], $types);
@@ -249,6 +249,19 @@ class AccountRepository implements AccountRepositoryInterface
$query->accountTypeIn($types);
}
// process filters
// TODO this should be repeatable, it feels like a hack when you do it here.
foreach($filters as $column => $value) {
// filter on NULL values
if(null === $value) {
continue;
}
if ('active' === $column) {
$query->where('accounts.active', $value);
}
}
// add sort parameters. At this point they're filtered to allowed fields to sort by:
$hasActiveColumn = array_key_exists('active', $sort);
if (count($sort) > 0) {

View File

@@ -55,7 +55,7 @@ interface AccountRepositoryInterface
public function getAccountsById(array $accountIds): Collection;
public function getAccountsByType(array $types, ?array $sort = []): Collection;
public function getAccountsByType(array $types, ?array $sort = [], ?array $filters = []): Collection;
/**
* Used in the infinite accounts list.

View File

@@ -0,0 +1,69 @@
<?php
/*
* GetFilterInstructions.php
* Copyright (c) 2024 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/.
*/
declare(strict_types=1);
namespace FireflyIII\Support\Request;
trait GetFilterInstructions
{
private const string INVALID_FILTER = '%INVALID_JAMES_%';
final public function getFilterInstructions(string $key): array
{
$config = config(sprintf('firefly.filters.allowed.%s', $key));
$allowed = array_keys($config);
$set = $this->get('filters', []);
$result = [];
if (0 === count($set)) {
return [];
}
foreach ($set as $info) {
$column = $info['column'] ?? 'NOPE';
$filterValue = (string) ($info['filter'] ?? self::INVALID_FILTER);
if (false === in_array($column, $allowed, true)) {
// skip invalid column
continue;
}
$filterType = $config[$column] ?? false;
switch ($filterType) {
default:
die(sprintf('Do not support filter type "%s"', $filterType));
case 'boolean':
$filterValue = $this->booleanInstruction($filterValue);
break;
}
$result[$column] = $filterValue;
}
return $result;
}
public function booleanInstruction(string $filterValue): ?bool {
if ('true' === $filterValue) {
return true;
}
if ('false' === $filterValue) {
return false;
}
return null;
}
}

View File

@@ -436,7 +436,7 @@ return [
'transfers' => 'fa-exchange',
],
'bindables' => [
'bindables' => [
// models
'account' => Account::class,
'attachment' => Attachment::class,
@@ -494,7 +494,7 @@ return [
'userGroupBill' => UserGroupBill::class,
'userGroup' => UserGroup::class,
],
'rule-actions' => [
'rule-actions' => [
'set_category' => SetCategory::class,
'clear_category' => ClearCategory::class,
'set_budget' => SetBudget::class,
@@ -528,7 +528,7 @@ return [
// 'set_foreign_amount' => SetForeignAmount::class,
// 'set_foreign_currency' => SetForeignCurrency::class,
],
'context-rule-actions' => [
'context-rule-actions' => [
'set_category',
'set_budget',
'add_tag',
@@ -547,13 +547,13 @@ return [
'convert_transfer',
],
'test-triggers' => [
'test-triggers' => [
'limit' => 10,
'range' => 200,
],
// expected source types for each transaction type, in order of preference.
'expected_source_types' => [
'expected_source_types' => [
'source' => [
TransactionTypeModel::WITHDRAWAL => [AccountType::ASSET, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE],
TransactionTypeEnum::DEPOSIT->value => [AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE, AccountType::REVENUE, AccountType::CASH],
@@ -598,7 +598,7 @@ return [
TransactionTypeModel::LIABILITY_CREDIT => [AccountType::LIABILITY_CREDIT, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE],
],
],
'allowed_opposing_types' => [
'allowed_opposing_types' => [
'source' => [
AccountType::ASSET => [
AccountType::ASSET,
@@ -688,7 +688,7 @@ return [
],
],
// depending on the account type, return the allowed transaction types:
'allowed_transaction_types' => [
'allowed_transaction_types' => [
'source' => [
AccountType::ASSET => [
TransactionTypeModel::WITHDRAWAL,
@@ -757,7 +757,7 @@ return [
],
// having the source + dest will tell you the transaction type.
'account_to_transaction' => [
'account_to_transaction' => [
AccountType::ASSET => [
AccountType::ASSET => TransactionTypeModel::TRANSFER,
AccountType::CASH => TransactionTypeModel::WITHDRAWAL,
@@ -822,7 +822,7 @@ return [
],
// allowed source -> destination accounts.
'source_dests' => [
'source_dests' => [
TransactionTypeModel::WITHDRAWAL => [
AccountType::ASSET => [AccountType::EXPENSE, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE, AccountType::CASH],
AccountType::LOAN => [AccountType::EXPENSE, AccountType::CASH],
@@ -861,7 +861,7 @@ return [
],
],
// if you add fields to this array, don't forget to update the export routine (ExportDataGenerator).
'journal_meta_fields' => [
'journal_meta_fields' => [
// sepa
'sepa_cc',
'sepa_ct_op',
@@ -895,33 +895,47 @@ return [
'recurrence_count',
'recurrence_date',
],
'webhooks' => [
'webhooks' => [
'max_attempts' => env('WEBHOOK_MAX_ATTEMPTS', 3),
],
'can_have_virtual_amounts' => [AccountType::ASSET],
'can_have_opening_balance' => [AccountType::ASSET, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE],
'dynamic_creation_allowed' => [
'can_have_virtual_amounts' => [AccountType::ASSET],
'can_have_opening_balance' => [AccountType::ASSET, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE],
'dynamic_creation_allowed' => [
AccountType::EXPENSE,
AccountType::REVENUE,
AccountType::INITIAL_BALANCE,
AccountType::RECONCILIATION,
AccountType::LIABILITY_CREDIT,
],
'valid_asset_fields' => ['account_role', 'account_number', 'currency_id', 'BIC', 'include_net_worth'],
'valid_cc_fields' => ['account_role', 'cc_monthly_payment_date', 'cc_type', 'account_number', 'currency_id', 'BIC', 'include_net_worth'],
'valid_account_fields' => ['account_number', 'currency_id', 'BIC', 'interest', 'interest_period', 'include_net_worth', 'liability_direction'],
'valid_asset_fields' => ['account_role', 'account_number', 'currency_id', 'BIC', 'include_net_worth'],
'valid_cc_fields' => ['account_role', 'cc_monthly_payment_date', 'cc_type', 'account_number', 'currency_id', 'BIC', 'include_net_worth'],
'valid_account_fields' => ['account_number', 'currency_id', 'BIC', 'interest', 'interest_period', 'include_net_worth', 'liability_direction'],
// dynamic date ranges are as follows:
'dynamic_date_ranges' => ['last7', 'last30', 'last90', 'last365', 'MTD', 'QTD', 'YTD'],
'dynamic_date_ranges' => ['last7', 'last30', 'last90', 'last365', 'MTD', 'QTD', 'YTD'],
// only used in v1
'allowed_sort_parameters' => ['order', 'name', 'iban'],
'allowed_sort_parameters' => ['order', 'name', 'iban'],
// preselected account lists possibilities:
'preselected_accounts' => ['all', 'assets', 'liabilities'],
'preselected_accounts' => ['all', 'assets', 'liabilities'],
// allowed sort columns for API's
'sorting' => [
// allowed filters (search) for APIs
'filters' => [
'allowed' => [
'accounts' => [
'name' => '*',
'active' => 'boolean',
'iban' => 'iban',
'balance' => 'numeric',
'last_activity' => 'date',
'balance_difference' => 'numeric',
],
],
],
// allowed sort columns for APIs
'sorting' => [
'allowed' => [
'transactions' => ['description', 'amount'],
'accounts' => ['name', 'active', 'iban', 'balance', 'last_activity', 'balance_difference'],

View File

@@ -1,58 +0,0 @@
/*!
* _variables.scss
* Copyright (c) 2019 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/>.
*/
/* TODO REMOVE ME */
// Body
$body-bg: #f5f8fa;
// Borders
$laravel-border-color: darken($body-bg, 10%);
$list-group-border: $laravel-border-color;
$navbar-default-border: $laravel-border-color;
$panel-default-border: $laravel-border-color;
$panel-inner-border: $laravel-border-color;
// Brands
$brand-primary: #3097D1;
$brand-info: #8eb4cb;
$brand-success: #2ab27b;
$brand-warning: #cbb956;
$brand-danger: #bf5329;
// Typography
$icon-font-path: "~bootstrap-sass/assets/fonts/bootstrap/";
$font-family-sans-serif: "Raleway", sans-serif;
$font-size-base: 14px;
$line-height-base: 1.6;
$text-color: #636b6f;
// Navbar
$navbar-default-bg: #fff;
// Buttons
$btn-default-color: $text-color;
// Inputs
$input-border: lighten($text-color, 40%);
$input-border-focus: lighten($brand-primary, 25%);
$input-color-placeholder: lighten($text-color, 30%);
// Panels
$panel-default-heading-bg: #fff;

View File

@@ -20,6 +20,7 @@
import {api} from "../../../../boot/axios";
import format from "date-fns/format";
import {getCacheKey} from "../../../../support/get-cache-key.js";
export default class Get {
@@ -39,7 +40,24 @@ export default class Get {
* @returns {Promise<AxiosResponse<any>>}
*/
index(params) {
return api.get('/api/v2/accounts', {params: params});
// first, check API in some consistent manner.
// then, load if necessary.
const cacheKey = getCacheKey('/api/v2/accounts', params);
const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(cacheKey);
if (cacheValid && typeof cachedData !== 'undefined') {
console.log('Cache is valid, return cache.');
return Promise.resolve(cachedData);
}
// if not, store in cache and then return res.
return api.get('/api/v2/accounts', {params: params}).then(response => {
console.log('Cache is invalid, return fresh.');
window.store.set(cacheKey, response.data);
return Promise.resolve({data: response.data.data, meta: response.data.meta});
});
}
/**

View File

@@ -24,16 +24,15 @@ import i18next from "i18next";
import {format} from "date-fns";
import formatMoney from "../../util/format-money.js";
import '@ag-grid-community/styles/ag-grid.css';
import '@ag-grid-community/styles/ag-theme-alpine.css';
import '../../css/grid-ff3-theme.css';
import Get from "../../api/v2/model/account/get.js";
import Put from "../../api/v2/model/account/put.js";
import AccountRenderer from "../../support/renderers/AccountRenderer.js";
import {showInternalsButton} from "../../support/page-settings/show-internals-button.js";
import {showWizardButton} from "../../support/page-settings/show-wizard-button.js";
import {getVariable} from "../../store/get-variable.js";
import {setVariable} from "../../store/set-variable.js";
import {getVariables} from "../../store/get-variables.js";
import pageNavigation from "../../support/page-navigation.js";
import {getCacheKey} from "../../support/get-cache-key.js";
// set type from URL
@@ -43,6 +42,7 @@ const type = urlParts[urlParts.length - 1];
let sortingColumn = '';
let sortDirection = '';
let page = 1;
// get sort parameters
const params = new Proxy(new URLSearchParams(window.location.search), {
@@ -50,11 +50,15 @@ const params = new Proxy(new URLSearchParams(window.location.search), {
});
sortingColumn = params.column ?? '';
sortDirection = params.direction ?? '';
page = parseInt(params.page ?? 1);
showInternalsButton();
showWizardButton();
// TODO currency conversion
// TODO page cleanup and recycle for transaction lists.
let index = function () {
return {
// notifications
@@ -70,11 +74,13 @@ let index = function () {
},
totalPages: 1,
page: 1,
pageUrl: '',
filters: {
active: 'both',
active: null,
name: null,
},
pageOptions: {
isLoading: true,
groupedAccounts: true,
sortingColumn: sortingColumn,
sortDirection: sortDirection,
@@ -138,29 +144,46 @@ let index = function () {
},
editors: {},
accounts: [],
goToPage(page) {
this.page = page;
this.loadAccounts();
},
accountRole(roleName) {
return i18next.t('firefly.account_role_' + roleName);
},
getPreferenceKey(name) {
return 'acc_index_' + type + '_' + name;
},
pageNavigation() {
return pageNavigation(this.totalPages, this.page, this.generatePageUrl(false));
},
sort(column) {
this.page =1;
this.pageOptions.sortingColumn = column;
this.pageOptions.sortDirection = this.pageOptions.sortDirection === 'asc' ? 'desc' : 'asc';
const url = './accounts/' + type + '?column=' + column + '&direction=' + this.pageOptions.sortDirection;
window.history.pushState({}, "", url);
this.updatePageUrl();
// get sort column
// TODO variable name in better place
const columnKey = 'acc_index_' + type + '_sc';
const directionKey = 'acc_index_' + type + '_sd';
setVariable(columnKey, this.pageOptions.sortingColumn);
setVariable(directionKey, this.pageOptions.sortDirection);
setVariable(this.getPreferenceKey('sc'), this.pageOptions.sortingColumn);
setVariable(this.getPreferenceKey('sd'), this.pageOptions.sortDirection);
this.loadAccounts();
return false;
},
updatePageUrl() {
this.pageUrl = this.generatePageUrl(true);
window.history.pushState({}, "", this.pageUrl);
},
generatePageUrl(includePageNr) {
let url = './accounts/' + type + '?column=' + this.pageOptions.sortingColumn + '&direction=' + this.pageOptions.sortDirection + '&page=';
if(includePageNr) {
return url + this.page
}
return url;
},
formatMoney(amount, currencyCode) {
return formatMoney(amount, currencyCode);
@@ -177,56 +200,58 @@ let index = function () {
}
}
console.log('New settings', newSettings);
setVariable('acc_index_' + type + '_columns', newSettings);
setVariable(this.getPreferenceKey('columns'), newSettings);
},
init() {
this.pageOptions.isLoading = true;
this.notifications.wait.show = true;
this.page = page;
this.notifications.wait.text = i18next.t('firefly.wait_loading_data');
// get column preference
// TODO key in better variable
const key = 'acc_index_' + type + '_columns';
const defaultValue = {"drag_and_drop": false};
// get sort column
const columnKey = 'acc_index_' + type + '_sc';
const columnDefault = '';
// get sort direction
const directionKey = 'acc_index_' + type + '_sd';
const directionDefault = '';
getVariable(key, defaultValue).then((response) => {
for (let k in response) {
if (response.hasOwnProperty(k) && this.tableColumns.hasOwnProperty(k)) {
this.tableColumns[k].enabled = response[k] ?? true;
// start by collecting all preferences, create + put in the local store.
getVariables([
{name: this.getPreferenceKey('columns'), default: {"drag_and_drop": false}},
{name: this.getPreferenceKey('sc'), default: ''},
{name: this.getPreferenceKey('sd'), default: ''},
{name: this.getPreferenceKey('filters'), default: this.filters},
]).then((res) => {
// process columns:
for (let k in res[0]) {
if (res[0].hasOwnProperty(k) && this.tableColumns.hasOwnProperty(k)) {
this.tableColumns[k].enabled = res[0][k] ?? true;
}
}
}).
// get sorting preference, and overrule it if is not "" twice
then(() => {
return getVariable(columnKey, columnDefault).then((response) => {
console.log('Sorting column is "' + response + '"');
this.pageOptions.sortingColumn = '' === this.pageOptions.sortingColumn ? response : this.pageOptions.sortingColumn;
})
})
.
// get sorting preference, and overrule it if is not "" twice
then(() => {
return getVariable(directionKey, directionDefault).then((response) => {
console.log('Sorting direction is "' + response + '"');
this.pageOptions.sortDirection = '' === this.pageOptions.sortDirection ? response : this.pageOptions.sortDirection;
})
}).
// process sorting column:
this.pageOptions.sortingColumn = '' === this.pageOptions.sortingColumn ? res[1] : this.pageOptions.sortingColumn;
// process sort direction
this.pageOptions.sortDirection = '' === this.pageOptions.sortDirection ? res[2] : this.pageOptions.sortDirection;
// filters
for(let k in res[3]) {
if (res[3].hasOwnProperty(k) && this.filters.hasOwnProperty(k)) {
this.filters[k] = res[3][k];
}
}
then(() => {
this.loadAccounts();
});
},
saveActiveFilter(e) {
this.page = 1;
if('both' === e.currentTarget.value) {
this.filters.active = null;
}
if('active' === e.currentTarget.value) {
this.filters.active = true;
}
if('inactive' === e.currentTarget.value) {
this.filters.active = false;
}
setVariable(this.getPreferenceKey('filters'), this.filters);
this.loadAccounts();
},
renderObjectValue(field, account) {
let renderer = new AccountRenderer();
@@ -270,36 +295,47 @@ let index = function () {
this.accounts[index].nameEditorVisible = true;
},
loadAccounts() {
this.pageOptions.isLoading = true;
// sort instructions
const sorting = [{column: this.pageOptions.sortingColumn, direction: this.pageOptions.sortDirection}];
// filter instructions
let filters = [];
for(let k in this.filters) {
if(this.filters.hasOwnProperty(k) && null !== this.filters[k]) {
filters.push({column: k, filter: this.filters[k]});
}
}
// get start and end from the store:
const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end'));
const today = new Date();
let params = {
sorting: sorting,
filters: filters,
today: today,
type: type,
page: this.page,
start: start,
end: end
};
if(!this.tableColumns.balance_difference.enabled){
if (!this.tableColumns.balance_difference.enabled) {
delete params.start;
delete params.end;
}
// check if cache is present:
this.notifications.wait.show = true;
this.notifications.wait.text = i18next.t('firefly.wait_loading_data')
this.accounts = [];
// one page only.o
(new Get()).index(params).then(response => {
for (let i = 0; i < response.data.data.length; i++) {
if (response.data.data.hasOwnProperty(i)) {
let current = response.data.data[i];
this.totalPages = response.meta.pagination.total_pages;
for (let i = 0; i < response.data.length; i++) {
if (response.data.hasOwnProperty(i)) {
let current = response.data[i];
let account = {
id: parseInt(current.id),
active: current.attributes.active,
@@ -317,11 +353,11 @@ let index = function () {
balance_difference: current.attributes.balance_difference,
native_balance_difference: current.attributes.native_balance_difference
};
console.log(current.attributes.balance_difference);
this.accounts.push(account);
}
}
this.notifications.wait.show = false;
this.pageOptions.isLoading = false;
// add click trigger thing.
});
},

View File

@@ -63,7 +63,6 @@ let administrations = function () {
pageProperties: {},
submitForm() {
console.log('submitForm');
(new Put()).put({title: this.title}, {id: this.id}).then(response => {
if (this.formStates.returnHereButton) {
this.notifications.success.show = true;

View File

@@ -46,16 +46,18 @@ export default () => ({
this.autoConversion = !this.autoConversion;
setVariable('autoConversion', this.autoConversion);
},
localCacheKey(type) {
return 'ds_accounts_' + type;
},
getFreshData() {
const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end'));
const chartCacheKey = getCacheKey('dashboard-accounts-chart', start, end)
const chartCacheKey = getCacheKey(this.localCacheKey('chart'), {start: start, end: end})
const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(chartCacheKey);
if (cacheValid && typeof cachedData !== 'undefined') {
console.log(cachedData);
this.drawChart(this.generateOptions(cachedData));
this.loading = false;
return;
@@ -65,7 +67,6 @@ export default () => ({
this.chartData = response.data;
// cache generated options:
window.store.set(chartCacheKey, response.data);
console.log(response.data);
this.drawChart(this.generateOptions(this.chartData));
this.loading = false;
});
@@ -168,7 +169,7 @@ export default () => ({
}
const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end'));
const accountCacheKey = getCacheKey('dashboard-accounts-data', start, end);
const accountCacheKey = getCacheKey(this.localCacheKey('data'), {start: start, end: end});
const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(accountCacheKey);
@@ -221,7 +222,6 @@ export default () => ({
// if transfer and source is this account, multiply again
if('transfer' === currentTransaction.type && parseInt(currentTransaction.source_id) === accountId) { //
console.log('transfer', parseInt(currentTransaction.source_id), accountId);
nativeAmountRaw = nativeAmountRaw * -1;
amountRaw = amountRaw * -1;
}

View File

@@ -38,7 +38,8 @@ export default () => ({
getFreshData() {
const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end'));
const boxesCacheKey = getCacheKey('dashboard-boxes-data', start, end);
// TODO cache key is hard coded, problem?
const boxesCacheKey = getCacheKey('ds_boxes_data', {start: start, end: end});
cleanupCache();
const cacheValid = window.store.get('cacheValid');
@@ -208,6 +209,7 @@ export default () => ({
// Getter
init() {
// console.log('boxes init');
// TODO can be replaced by "getVariables"
Promise.all([getVariable('viewRange'), getVariable('autoConversion', false)]).then((values) => {
// console.log('boxes after promises');
afterPromises = true;

View File

@@ -59,7 +59,7 @@ export default () => ({
getFreshData() {
const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end'));
const cacheKey = getCacheKey('dashboard-budgets-chart', start, end);
const cacheKey = getCacheKey('ds_bdg_chart', {start: start, end: end});
const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(cacheKey);

View File

@@ -147,7 +147,7 @@ export default () => ({
getFreshData() {
const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end'));
const cacheKey = getCacheKey('dashboard-categories-chart', start, end);
const cacheKey = getCacheKey('ds_ct_chart', {start: start, end: end});
const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(cacheKey);

View File

@@ -25,7 +25,7 @@ import i18next from "i18next";
let apiData = {};
let afterPromises = false;
const PIGGY_CACHE_KEY = 'dashboard-piggies-data';
const PIGGY_CACHE_KEY = 'ds_pg_data';
export default () => ({
loading: false,
@@ -36,7 +36,7 @@ export default () => ({
const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end'));
// needs user data.
const cacheKey = getCacheKey(PIGGY_CACHE_KEY, start, end);
const cacheKey = getCacheKey(PIGGY_CACHE_KEY, {start: start, end: end});
const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(cacheKey);
@@ -58,7 +58,7 @@ export default () => ({
downloadPiggyBanks(params) {
const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end'));
const cacheKey = getCacheKey(PIGGY_CACHE_KEY, start, end);
const cacheKey = getCacheKey(PIGGY_CACHE_KEY, {start: start, end: end});
const getter = new Get();
getter.list(params).then((response) => {
apiData = [...apiData, ...response.data.data];

View File

@@ -28,7 +28,7 @@ import i18next from "i18next";
Chart.register({SankeyController, Flow});
const SANKEY_CACHE_KEY = 'dashboard-sankey-data';
const SANKEY_CACHE_KEY = 'ds_sankey_data';
let currencies = [];
let afterPromises = false;
let chart = null;
@@ -288,7 +288,7 @@ export default () => ({
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 cacheKey = getCacheKey(SANKEY_CACHE_KEY, {start: start, end: end});
const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(cacheKey);
@@ -312,7 +312,7 @@ export default () => ({
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);
const cacheKey = getCacheKey(SANKEY_CACHE_KEY, {start: start, end: end});
//console.log('Downloading page ' + params.page + '...');
const getter = new Get();

View File

@@ -186,7 +186,7 @@ export default () => ({
let end = new Date(window.store.get('end'));
const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(getCacheKey('subscriptions-data-dashboard', start, end));
let cachedData = window.store.get(getCacheKey('ds_sub_data', {start: start, end: end}));
if (cacheValid && typeof cachedData !== 'undefined' && false) {
console.error('cannot handle yet');

View File

@@ -0,0 +1,47 @@
/*
* get-variable.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 "./get-variable.js";
export function getVariables(preferences) {
let chain = Promise.resolve();
let allVariables = [];
for (let i = 0; i < preferences.length; i++) {
let current = preferences[i];
let name = current.name;
let defaultValue = current.default;
chain = chain.then(() => {
return getVariable(name, defaultValue).then((value) => {
allVariables.push(value);
return Promise.resolve(allVariables);
});
});
}
return chain;
}
export function parseResponse(name, response) {
let value = response.data.data.attributes.data;
window.store.set(name, value);
return value;
}

View File

@@ -20,12 +20,77 @@
import {format} from "date-fns";
import store from "store";
//const { createHash } = require('crypto');
function getCacheKey(string, start, end) {
const localValue = store.get('lastActivity');
const cacheKey = 'dcx' + format(start, 'yMMdd')+ format(end, 'yMMdd') + string + localValue;
console.log('getCacheKey: ' + cacheKey);
return String(cacheKey);
function getCacheKey(string, params) {
const lastActivity = store.get('lastActivity')
let newParams = {lastActivity: lastActivity, key: string};
for (const key in params) {
if (params.hasOwnProperty(key)) {
if(params[key] === null || params[key] === undefined) {
newParams[key] = '';
continue;
}
if(params[key] instanceof Date) {
newParams[key] = format(params[key], 'yMMdd');
continue;
}
newParams[key] = params[key];
}
}
return 'dcx_' + md5(JSON.stringify(newParams)).substring(0,12) + lastActivity;
}
// Formatted version of a popular md5 implementation
// Original copyright (c) Paul Johnston & Greg Holt.
// The function itself is now 42 lines long.
function md5(inputString) {
var hc="0123456789abcdef";
function rh(n) {var j,s="";for(j=0;j<=3;j++) s+=hc.charAt((n>>(j*8+4))&0x0F)+hc.charAt((n>>(j*8))&0x0F);return s;}
function ad(x,y) {var l=(x&0xFFFF)+(y&0xFFFF);var m=(x>>16)+(y>>16)+(l>>16);return (m<<16)|(l&0xFFFF);}
function rl(n,c) {return (n<<c)|(n>>>(32-c));}
function cm(q,a,b,x,s,t) {return ad(rl(ad(ad(a,q),ad(x,t)),s),b);}
function ff(a,b,c,d,x,s,t) {return cm((b&c)|((~b)&d),a,b,x,s,t);}
function gg(a,b,c,d,x,s,t) {return cm((b&d)|(c&(~d)),a,b,x,s,t);}
function hh(a,b,c,d,x,s,t) {return cm(b^c^d,a,b,x,s,t);}
function ii(a,b,c,d,x,s,t) {return cm(c^(b|(~d)),a,b,x,s,t);}
function sb(x) {
var i;var nblk=((x.length+8)>>6)+1;var blks=new Array(nblk*16);for(i=0;i<nblk*16;i++) blks[i]=0;
for(i=0;i<x.length;i++) blks[i>>2]|=x.charCodeAt(i)<<((i%4)*8);
blks[i>>2]|=0x80<<((i%4)*8);blks[nblk*16-2]=x.length*8;return blks;
}
var i,x=sb(""+inputString),a=1732584193,b=-271733879,c=-1732584194,d=271733878,olda,oldb,oldc,oldd;
for(i=0;i<x.length;i+=16) {olda=a;oldb=b;oldc=c;oldd=d;
a=ff(a,b,c,d,x[i+ 0], 7, -680876936);d=ff(d,a,b,c,x[i+ 1],12, -389564586);c=ff(c,d,a,b,x[i+ 2],17, 606105819);
b=ff(b,c,d,a,x[i+ 3],22,-1044525330);a=ff(a,b,c,d,x[i+ 4], 7, -176418897);d=ff(d,a,b,c,x[i+ 5],12, 1200080426);
c=ff(c,d,a,b,x[i+ 6],17,-1473231341);b=ff(b,c,d,a,x[i+ 7],22, -45705983);a=ff(a,b,c,d,x[i+ 8], 7, 1770035416);
d=ff(d,a,b,c,x[i+ 9],12,-1958414417);c=ff(c,d,a,b,x[i+10],17, -42063);b=ff(b,c,d,a,x[i+11],22,-1990404162);
a=ff(a,b,c,d,x[i+12], 7, 1804603682);d=ff(d,a,b,c,x[i+13],12, -40341101);c=ff(c,d,a,b,x[i+14],17,-1502002290);
b=ff(b,c,d,a,x[i+15],22, 1236535329);a=gg(a,b,c,d,x[i+ 1], 5, -165796510);d=gg(d,a,b,c,x[i+ 6], 9,-1069501632);
c=gg(c,d,a,b,x[i+11],14, 643717713);b=gg(b,c,d,a,x[i+ 0],20, -373897302);a=gg(a,b,c,d,x[i+ 5], 5, -701558691);
d=gg(d,a,b,c,x[i+10], 9, 38016083);c=gg(c,d,a,b,x[i+15],14, -660478335);b=gg(b,c,d,a,x[i+ 4],20, -405537848);
a=gg(a,b,c,d,x[i+ 9], 5, 568446438);d=gg(d,a,b,c,x[i+14], 9,-1019803690);c=gg(c,d,a,b,x[i+ 3],14, -187363961);
b=gg(b,c,d,a,x[i+ 8],20, 1163531501);a=gg(a,b,c,d,x[i+13], 5,-1444681467);d=gg(d,a,b,c,x[i+ 2], 9, -51403784);
c=gg(c,d,a,b,x[i+ 7],14, 1735328473);b=gg(b,c,d,a,x[i+12],20,-1926607734);a=hh(a,b,c,d,x[i+ 5], 4, -378558);
d=hh(d,a,b,c,x[i+ 8],11,-2022574463);c=hh(c,d,a,b,x[i+11],16, 1839030562);b=hh(b,c,d,a,x[i+14],23, -35309556);
a=hh(a,b,c,d,x[i+ 1], 4,-1530992060);d=hh(d,a,b,c,x[i+ 4],11, 1272893353);c=hh(c,d,a,b,x[i+ 7],16, -155497632);
b=hh(b,c,d,a,x[i+10],23,-1094730640);a=hh(a,b,c,d,x[i+13], 4, 681279174);d=hh(d,a,b,c,x[i+ 0],11, -358537222);
c=hh(c,d,a,b,x[i+ 3],16, -722521979);b=hh(b,c,d,a,x[i+ 6],23, 76029189);a=hh(a,b,c,d,x[i+ 9], 4, -640364487);
d=hh(d,a,b,c,x[i+12],11, -421815835);c=hh(c,d,a,b,x[i+15],16, 530742520);b=hh(b,c,d,a,x[i+ 2],23, -995338651);
a=ii(a,b,c,d,x[i+ 0], 6, -198630844);d=ii(d,a,b,c,x[i+ 7],10, 1126891415);c=ii(c,d,a,b,x[i+14],15,-1416354905);
b=ii(b,c,d,a,x[i+ 5],21, -57434055);a=ii(a,b,c,d,x[i+12], 6, 1700485571);d=ii(d,a,b,c,x[i+ 3],10,-1894986606);
c=ii(c,d,a,b,x[i+10],15, -1051523);b=ii(b,c,d,a,x[i+ 1],21,-2054922799);a=ii(a,b,c,d,x[i+ 8], 6, 1873313359);
d=ii(d,a,b,c,x[i+15],10, -30611744);c=ii(c,d,a,b,x[i+ 6],15,-1560198380);b=ii(b,c,d,a,x[i+13],21, 1309151649);
a=ii(a,b,c,d,x[i+ 4], 6, -145523070);d=ii(d,a,b,c,x[i+11],10,-1120210379);c=ii(c,d,a,b,x[i+ 2],15, 718787259);
b=ii(b,c,d,a,x[i+ 9],21, -343485551);a=ad(a,olda);b=ad(b,oldb);c=ad(c,oldc);d=ad(d,oldd);
}
return rh(a)+rh(b)+rh(c)+rh(d);
}
export {getCacheKey};

View File

@@ -0,0 +1,95 @@
/*
* page-navigation.js
* Copyright (c) 2024 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/.
*/
function logarithmicPaginationLinks(lastPage, matchPage, linkURL) {
function pageLink(p, page) {
if(p === page) {
// href="'+ linkURL+ p + '"
return '<li class="page-item active" aria-current="page"><a class="page-link" href="#" @click.prevent="goToPage('+p+')">'+p+'</a></li>';
}
// href="'+ linkURL+ p + '"
return '<li class="page-item"><a class="page-link" href="#" @click.prevent="goToPage('+p+')">'+p+'</a></li>';
// return ((p === page) ? "<b>" + p + "</b>" : '<a href="' + linkURL + p + '">' + p + "</a>");
}
let page = (matchPage ? matchPage : 1), LINKS_PER_STEP = 5, lastp1 = 1, lastp2 = page, p1 = 1, p2 = page,
c1 = LINKS_PER_STEP + 1, c2 = LINKS_PER_STEP + 1, s1 = "", s2 = "", step = 1, linkHTML = "";
while (true) {
if (c1 >= c2) {
s1 += pageLink(p1, matchPage);
lastp1 = p1;
p1 += step;
c1--;
} else {
s2 = pageLink(p2, matchPage) + s2;
lastp2 = p2;
p2 -= step;
c2--;
}
if (c2 === 0) {
step *= 25;
p1 += step - 1; // Round UP to nearest multiple of step
p1 -= (p1 % step);
p2 -= (p2 % step); // Round DOWN to nearest multiple of step
c1 = LINKS_PER_STEP;
c2 = LINKS_PER_STEP;
}
if (p1 > p2) {
linkHTML += s1 + s2;
if ((lastp2 > page) || (page >= lastPage)) break;
lastp1 = page;
lastp2 = lastPage;
p1 = page + 1;
p2 = lastPage;
c1 = LINKS_PER_STEP;
c2 = LINKS_PER_STEP + 1;
s1 = '';
s2 = '';
step = 1;
}
}
return linkHTML;
}
export default function pageNavigation(totalPages, currentPage, navigationURL) {
totalPages = parseInt(totalPages);
currentPage = parseInt(currentPage);
let html = '<nav aria-label="Page navigation">';
html += '<ul class="pagination">';
if(currentPage > 1) {
html += '<li class="page-item"><a class="page-link" href="#">Previous</a></li>';
}
if(1 === currentPage) {
html += '<li class="page-item disabled"><a class="page-link">Previous</a></li>';
}
html += logarithmicPaginationLinks(totalPages, currentPage, navigationURL);
if(currentPage !== totalPages) {
html += '<li class="page-item"><a class="page-link" href="#">Next</a></li>';
}
if(currentPage === totalPages) {
html += '<li class="page-item disabled"><a class="page-link">Next</a></li>';
}
html += '</ul></nav>';
return html;
}

View File

@@ -37,7 +37,7 @@
</div>
<div class="row mb-3">
<div class="col">
Nav
<div x-html="pageNavigation()">
</div>
</div>
<div class="row mb-3">
@@ -97,9 +97,9 @@
<th x-show="tableColumns.active.visible && tableColumns.active.enabled">
<a href="#" x-on:click.prevent="sort('active')">Active?</a>
<em x-show="pageOptions.sortingColumn === 'active' && pageOptions.sortDirection === 'asc'"
class="fa-solid fa-arrow-down-wide-short"></em>
class="fa-solid fa-arrow-down-short-wide"></em>
<em x-show="pageOptions.sortingColumn === 'active' && pageOptions.sortDirection === 'desc'"
class="fa-solid fa-arrow-up-wide-short"></em>
class="fa-solid fa-arrow-down-wide-short"></em>
</th>
<th x-show="tableColumns.name.visible && tableColumns.name.enabled">
<a href="#" x-on:click.prevent="sort('name')">Name</a>
@@ -121,16 +121,16 @@
<th x-show="tableColumns.number.visible && tableColumns.number.enabled">
<a href="#" x-on:click.prevent="sort('iban')">Account number</a>
<em x-show="pageOptions.sortingColumn === 'iban' && pageOptions.sortDirection === 'asc'"
class="fa-solid fa-arrow-down-z-a"></em>
class="fa-solid fa-arrow-down-a-z"></em>
<em x-show="pageOptions.sortingColumn === 'iban' && pageOptions.sortDirection === 'desc'"
class="fa-solid fa-arrow-up-z-a"></em>
class="fa-solid fa-arrow-down-z-a"></em>
</th>
<th x-show="tableColumns.current_balance.visible && tableColumns.current_balance.enabled">
<a href="#" x-on:click.prevent="sort('balance')">Current balance</a>
<em x-show="pageOptions.sortingColumn === 'balance' && pageOptions.sortDirection === 'asc'"
class="fa-solid fa-arrow-down-wide-short"></em>
class="fa-solid fa-arrow-down-9-1"></em>
<em x-show="pageOptions.sortingColumn === 'balance' && pageOptions.sortDirection === 'desc'"
class="fa-solid fa-arrow-up-wide-short"></em>
class="fa-solid fa-arrow-down-1-9"></em>
</th>
<th x-show="tableColumns.amount_due.visible && tableColumns.amount_due.enabled">
<a href="#" x-on:click.prevent="sort('amount_due')">Amount due</a>
@@ -156,6 +156,11 @@
</th>
<th x-show="tableColumns.menu.visible && tableColumns.menu.enabled">&nbsp;</th>
</tr>
<tr x-show="pageOptions.isLoading">
<td colspan="13" class="text-center">
<span class="fa fa-spin fa-spinner"></span>
</td>
</tr>
</thead>
<tbody>
<template x-for="(account, index) in accounts" :key="index">
@@ -237,7 +242,7 @@
</td>
<td x-show="tableColumns.current_balance.visible && tableColumns.current_balance.enabled">
<span
x-text="formatMoney(account.current_balance, account.currency_code)"></span>
x-text="formatMoney(account.native_current_balance, account.currency_code)"></span>
</td>
<td x-show="tableColumns.amount_due.visible && tableColumns.amount_due.enabled">
TODO
@@ -289,7 +294,7 @@
</div>
<div class="row mb-3">
<div class="col">
Nav
<div x-html="pageNavigation()">
</div>
</div>
@@ -320,11 +325,11 @@
<div class="row mb-3">
<label for="inputEmail3" class="col-sm-4 col-form-label">Active accounts?</label>
<div class="col-sm-8">
<select x-model="filters.active" class="form-control">
<option value="active" label="Active accounts">Active accounts only</option>
<option value="inactive" label="Inactive accounts">Inactive accounts only
<select @change="saveActiveFilter" class="form-control">
<option value="active" :selected="true === filters.active" label="Active accounts">Active accounts only</option>
<option value="inactive" :selected="false === filters.active" label="Inactive accounts">Inactive accounts only
</option>
<option value="both" label="Both">All accounts</option>
<option value="both" :selected="null === filters.active" label="Both">All accounts</option>
</select>
<div id="emailHelp" class="form-text">TODO Bla bla bla.</div>
</div>