From 88a9bc379e5678f6b12082c7564cd2ae5c73f492 Mon Sep 17 00:00:00 2001 From: James Cole Date: Mon, 4 Mar 2024 20:41:34 +0100 Subject: [PATCH] Expand account list --- .../Model/Account/IndexController.php | 78 +++++++ .../List/TransactionController.php | 2 +- .../Model/Transaction/InfiniteListRequest.php | 13 +- app/Helpers/Collector/GroupCollector.php | 1 + .../UserGroups/Account/AccountRepository.php | 62 ++++++ .../Account/AccountRepositoryInterface.php | 19 ++ .../Recurring/CalculateXOccurrencesSince.php | 3 +- app/Transformers/V2/AccountTransformer.php | 2 +- config/firefly.php | 1 + .../assets/v2/api/v2/model/account/get.js | 6 +- resources/assets/v2/pages/accounts/index.js | 210 ++++++++++++++++++ .../assets/v2/pages/transactions/index.js | 24 +- .../v2/support/ag-grid/AccountDataSource.js | 90 ++++++++ resources/views/v2/accounts/index.blade.php | 75 +++++++ routes/api.php | 13 +- routes/web.php | 4 +- vite.config.js | 4 + 17 files changed, 581 insertions(+), 26 deletions(-) create mode 100644 app/Api/V2/Controllers/Model/Account/IndexController.php create mode 100644 resources/assets/v2/pages/accounts/index.js create mode 100644 resources/assets/v2/support/ag-grid/AccountDataSource.js create mode 100644 resources/views/v2/accounts/index.blade.php diff --git a/app/Api/V2/Controllers/Model/Account/IndexController.php b/app/Api/V2/Controllers/Model/Account/IndexController.php new file mode 100644 index 0000000000..6fd12c51cb --- /dev/null +++ b/app/Api/V2/Controllers/Model/Account/IndexController.php @@ -0,0 +1,78 @@ +middleware( + function ($request, $next) { + $this->repository = app(AccountRepositoryInterface::class); + // new way of user group validation + $userGroup = $this->validateUserGroup($request); + if (null !== $userGroup) { + $this->repository->setUserGroup($userGroup); + } + + return $next($request); + } + ); + } + + public function infiniteList(InfiniteListRequest $request): JsonResponse + { + $this->repository->resetAccountOrder(); + + // get accounts of the specified type, and return. + $types = $request->getAccountTypes(); + + // get from repository + $accounts = $this->repository->getAccountsInOrder($types, $request->getSortInstructions('accounts'), $request->getStartRow(), $request->getEndRow()); + $total = $this->repository->countAccounts($types); + $count = $request->getEndRow() - $request->getStartRow(); + $paginator = new LengthAwarePaginator($accounts, $total, $count, $this->parameters->get('page')); + $transformer = new AccountTransformer(); + $transformer->setParameters($this->parameters); // give params to transformer + + return response() + ->json($this->jsonApiList(self::RESOURCE_KEY, $paginator, $transformer)) + ->header('Content-Type', self::CONTENT_TYPE); + } +} diff --git a/app/Api/V2/Controllers/Transaction/List/TransactionController.php b/app/Api/V2/Controllers/Transaction/List/TransactionController.php index 50731dbf73..6dbb617016 100644 --- a/app/Api/V2/Controllers/Transaction/List/TransactionController.php +++ b/app/Api/V2/Controllers/Transaction/List/TransactionController.php @@ -79,7 +79,7 @@ class TransactionController extends Controller public function infiniteList(InfiniteListRequest $request): JsonResponse { // get sort instructions - $instructions = $request->getSortInstructions(); + $instructions = $request->getSortInstructions('transactions'); // collect transactions: /** @var GroupCollectorInterface $collector */ diff --git a/app/Api/V2/Request/Model/Transaction/InfiniteListRequest.php b/app/Api/V2/Request/Model/Transaction/InfiniteListRequest.php index 90d6df7fe0..e8eff1f433 100644 --- a/app/Api/V2/Request/Model/Transaction/InfiniteListRequest.php +++ b/app/Api/V2/Request/Model/Transaction/InfiniteListRequest.php @@ -25,6 +25,7 @@ declare(strict_types=1); namespace FireflyIII\Api\V2\Request\Model\Transaction; use Carbon\Carbon; +use FireflyIII\Support\Http\Api\AccountFilter; use FireflyIII\Support\Http\Api\TransactionFilter; use FireflyIII\Support\Request\ChecksLogin; use FireflyIII\Support\Request\ConvertsDataTypes; @@ -36,6 +37,7 @@ use Illuminate\Foundation\Http\FormRequest; */ class InfiniteListRequest extends FormRequest { + use AccountFilter; use ChecksLogin; use ConvertsDataTypes; use TransactionFilter; @@ -88,9 +90,9 @@ class InfiniteListRequest extends FormRequest return 0 === $page || $page > 65536 ? 1 : $page; } - public function getSortInstructions(): array + public function getSortInstructions(string $key): array { - $allowed = config('firefly.sorting.allowed.transactions'); + $allowed = config(sprintf('firefly.sorting.allowed.%s', $key)); $set = $this->get('sorting', []); $result = []; if (0 === count($set)) { @@ -120,6 +122,13 @@ class InfiniteListRequest extends FormRequest return $this->mapTransactionTypes($type); } + public function getAccountTypes(): array + { + $type = (string)$this->get('type', 'default'); + + return $this->mapAccountTypes($type); + } + public function rules(): array { return [ diff --git a/app/Helpers/Collector/GroupCollector.php b/app/Helpers/Collector/GroupCollector.php index 0e3b1de766..ee0bbdf333 100644 --- a/app/Helpers/Collector/GroupCollector.php +++ b/app/Helpers/Collector/GroupCollector.php @@ -792,6 +792,7 @@ class GroupCollector implements GroupCollectorInterface $this->setLimit(50); } if (null !== $this->startRow && null !== $this->endRow) { + /** @var int $total */ $total = $this->endRow - $this->startRow; return new LengthAwarePaginator($set, $this->total, $total, 1); diff --git a/app/Repositories/UserGroups/Account/AccountRepository.php b/app/Repositories/UserGroups/Account/AccountRepository.php index c9a765f52a..8236c0f096 100644 --- a/app/Repositories/UserGroups/Account/AccountRepository.php +++ b/app/Repositories/UserGroups/Account/AccountRepository.php @@ -226,4 +226,66 @@ class AccountRepository implements AccountRepositoryInterface return $dbQuery->take($limit)->get(['accounts.*']); } + + public function resetAccountOrder(): void + { + $sets = [ + [AccountType::DEFAULT, AccountType::ASSET], + [AccountType::LOAN, AccountType::DEBT, AccountType::CREDITCARD, AccountType::MORTGAGE], + ]; + foreach ($sets as $set) { + $list = $this->getAccountsByType($set); + $index = 1; + foreach ($list as $account) { + if (false === $account->active) { + $account->order = 0; + + continue; + } + if ($index !== (int)$account->order) { + app('log')->debug(sprintf('Account #%d ("%s"): order should %d be but is %d.', $account->id, $account->name, $index, $account->order)); + $account->order = $index; + $account->save(); + } + ++$index; + } + } + } + + /** + * @inheritDoc + */ + #[\Override] public function getAccountsInOrder(array $types, array $sort, int $startRow, int $endRow): Collection + { + $query = $this->userGroup->accounts(); + if (0 !== count($types)) { + $query->accountTypeIn($types); + } + $query->skip($startRow); + $query->take($endRow - $startRow); + + // add sort parameters. At this point they're filtered to allowed fields to sort by: + if (0 !== count($sort)) { + foreach ($sort as $label => $direction) { + $query->orderBy(sprintf('accounts.%s', $label), $direction); + } + } + + if (0 === count($sort)) { + $query->orderBy('accounts.order', 'ASC'); + $query->orderBy('accounts.active', 'DESC'); + $query->orderBy('accounts.name', 'ASC'); + } + + return $query->get(['accounts.*']); + } + + #[\Override] public function countAccounts(array $types): int + { + $query = $this->userGroup->accounts(); + if (0 !== count($types)) { + $query->accountTypeIn($types); + } + return $query->count(); + } } diff --git a/app/Repositories/UserGroups/Account/AccountRepositoryInterface.php b/app/Repositories/UserGroups/Account/AccountRepositoryInterface.php index bd0b8116fb..cb98709818 100644 --- a/app/Repositories/UserGroups/Account/AccountRepositoryInterface.php +++ b/app/Repositories/UserGroups/Account/AccountRepositoryInterface.php @@ -49,6 +49,20 @@ interface AccountRepositoryInterface public function getAccountsByType(array $types, ?array $sort = []): Collection; + public function countAccounts(array $types): int; + + /** + * Used in the infinite accounts list. + * + * @param array $types + * @param array $sort + * @param int $startRow + * @param int $endRow + * + * @return Collection + */ +public function getAccountsInOrder(array $types, array $sort, int $startRow, int $endRow): Collection; + public function getActiveAccountsByType(array $types): Collection; /** @@ -61,4 +75,9 @@ interface AccountRepositoryInterface public function setUser(User $user): void; public function setUserGroup(UserGroup $userGroup): void; + + /** + * Reset order types of the mentioned accounts. + */ + public function resetAccountOrder(): void; } diff --git a/app/Support/Repositories/Recurring/CalculateXOccurrencesSince.php b/app/Support/Repositories/Recurring/CalculateXOccurrencesSince.php index 26eb8b8b98..4b766a0f47 100644 --- a/app/Support/Repositories/Recurring/CalculateXOccurrencesSince.php +++ b/app/Support/Repositories/Recurring/CalculateXOccurrencesSince.php @@ -79,13 +79,14 @@ trait CalculateXOccurrencesSince while ($total < $count) { $domCorrected = min($dayOfMonth, $mutator->daysInMonth); $mutator->day = $domCorrected; + app('log')->debug(sprintf('Mutator is now %s', $mutator->format('Y-m-d'))); if (0 === $attempts % $skipMod && $mutator->gte($afterDate)) { + app('log')->debug('Is added to the list.'); $return[] = clone $mutator; ++$total; } ++$attempts; $mutator = $mutator->endOfMonth()->addDay(); - app('log')->debug(sprintf('Mutator is now %s', $mutator->format('Y-m-d'))); } return $return; diff --git a/app/Transformers/V2/AccountTransformer.php b/app/Transformers/V2/AccountTransformer.php index c36d19f1f2..08d699a632 100644 --- a/app/Transformers/V2/AccountTransformer.php +++ b/app/Transformers/V2/AccountTransformer.php @@ -114,7 +114,7 @@ class AccountTransformer extends AbstractTransformer // no currency? use default $currency = $this->default; - if (0 !== (int)$this->accountMeta[$id]['currency_id']) { + if (array_key_exists($id, $this->accountMeta) && 0 !== (int)$this->accountMeta[$id]['currency_id']) { $currency = $this->currencies[(int)$this->accountMeta[$id]['currency_id']]; } // amounts and calculation. diff --git a/config/firefly.php b/config/firefly.php index 2745ea7797..10c2b9a27a 100644 --- a/config/firefly.php +++ b/config/firefly.php @@ -917,6 +917,7 @@ return [ 'sorting' => [ 'allowed' => [ 'transactions' => ['description', 'amount'], + 'accounts' => ['name'], ], ], ]; diff --git a/resources/assets/v2/api/v2/model/account/get.js b/resources/assets/v2/api/v2/model/account/get.js index 11f6f28997..a436163128 100644 --- a/resources/assets/v2/api/v2/model/account/get.js +++ b/resources/assets/v2/api/v2/model/account/get.js @@ -29,7 +29,7 @@ export default class Get { * @param date * @returns {Promise>} */ - get(identifier, date) { + list(identifier, date) { let params = {date: format(date, 'y-MM-dd').slice(0, 10)}; if (!date) { return api.get('/api/v2/accounts/' + identifier); @@ -37,6 +37,10 @@ export default class Get { return api.get('/api/v2/accounts/' + identifier, {params: params}); } + infiniteList(params) { + return api.get('/api/v2/infinite/accounts', {params: params}); + } + /** * * @param identifier diff --git a/resources/assets/v2/pages/accounts/index.js b/resources/assets/v2/pages/accounts/index.js new file mode 100644 index 0000000000..0797192d46 --- /dev/null +++ b/resources/assets/v2/pages/accounts/index.js @@ -0,0 +1,210 @@ +/* + * show.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/. + */ + +import '../../boot/bootstrap.js'; +import dates from "../shared/dates.js"; +import i18next from "i18next"; +import {format} from "date-fns"; +import formatMoney from "../../util/format-money.js"; +import Put from "../../api/v2/model/transaction/put.js"; + +import {createGrid, ModuleRegistry} from "@ag-grid-community/core"; + +import '@ag-grid-community/styles/ag-grid.css'; +import '@ag-grid-community/styles/ag-theme-alpine.css'; +import '../../css/grid-ff3-theme.css'; + +import AmountEditor from "../../support/ag-grid/AmountEditor.js"; + +import AccountDataSource from "../../support/ag-grid/AccountDataSource.js"; +import {InfiniteRowModelModule} from '@ag-grid-community/infinite-row-model'; +import DateTimeEditor from "../../support/ag-grid/DateTimeEditor.js"; + +const ds = new AccountDataSource(); + +// set type from URL +const urlParts = window.location.href.split('/'); +const type = urlParts[urlParts.length - 1]; +ds.setType(type); + +document.addEventListener('cellEditRequest', () => { + console.log('Loaded through event listener.'); + //loadPage(); +}); +let rowImmutableStore = []; + +let dataTable; +const editableFields = ['description', 'amount', 'date']; + +const onCellEditRequestMethod = (event) => { + console.log('onCellEditRequestMethod'); + const data = event.data; + const field = event.colDef.field; + let newValue = event.newValue; + if (!editableFields.includes(field)) { + console.log('Field ' + field + ' is not editable.'); + return; + } + + // this needs to be better + if ('amount' === field) { + newValue = event.newValue.amount; + console.log('New value is now' + newValue); + } + + console.log('New value for field "' + field + '" in transaction journal #' + data.transaction_journal_id + ' of group #' + data.id + ' is "' + newValue + '"'); + data[field] = newValue; + let rowNode = dataTable.getRowNode(String(event.rowIndex)); + rowNode.updateData(data); + + // then push update to Firefly III over API: + let submission = { + transactions: [ + { + transaction_journal_id: data.transaction_journal_id, + } + ] + }; + submission.transactions[0][field] = newValue; + + // let putter = new Put(); + // putter.put(submission, {id: data.id}); + + +}; + +document.addEventListener('cellValueChanged', () => { + console.log('I just realized a cell value has changed.'); +}); +document.addEventListener('onCellValueChanged', () => { + console.log('I just realized a cell value has changed.'); +}); + +let doOnCellValueChanged = function (e) { + console.log('I just realized a cell value has changed.'); +}; + +const gridOptions = { + rowModelType: 'infinite', + datasource: ds, + onCellEditRequest: onCellEditRequestMethod, + readOnlyEdit: true, + cacheOverflowSize: 1, + cacheBlockSize: 20, + // Row Data: The data to be displayed. + // rowData: [ + // { description: "Tesla", model: "Model Y", price: 64950, electric: true }, + // { description: "Ford", model: "F-Series", price: 33850, electric: false }, + // { description: "Toyota", model: "Corolla", price: 29600, electric: false }, + // ], + // Column Definitions: Defines & controls grid columns. + columnDefs: [ + { + field: "icon", + editable: false, + headerName: '', + sortable: false, + width: 40, + cellRenderer: function (params) { + if (params.getValue()) { + return ''; + } + return ''; + } + }, + { + field: "name", + cellDataType: 'text', + editable: true, + cellRenderer: function (params) { + if (params.getValue()) { + return ''+params.getValue() +''; + } + return ''; + } + + } + ] +}; + + +ModuleRegistry.registerModules([InfiniteRowModelModule]); +let index = function () { + return { + // notifications + notifications: { + error: { + show: false, text: '', url: '', + }, success: { + show: false, text: '', url: '', + }, wait: { + show: false, text: '', + + } + }, + totalPages: 1, + page: 1, + // available columns: + tableColumns: { + name: { + enabled: true + }, + }, + + table: null, + + formatMoney(amount, currencyCode) { + return formatMoney(amount, currencyCode); + }, + format(date) { + return format(date, i18next.t('config.date_time_fns')); + }, + init() { + this.notifications.wait.show = true; + this.notifications.wait.text = i18next.t('firefly.wait_loading_data') + + // Your Javascript code to create the grid + dataTable = createGrid(document.querySelector('#grid'), gridOptions); + + }, + } +} + +let comps = {index, dates}; + +function loadPage() { + Object.keys(comps).forEach(comp => { + console.log(`Loading page component "${comp}"`); + let data = comps[comp](); + Alpine.data(comp, () => data); + }); + Alpine.start(); +} + +// wait for load until bootstrapped event is received. +document.addEventListener('firefly-iii-bootstrapped', () => { + console.log('Loaded through event listener.'); + loadPage(); +}); +// or is bootstrapped before event is triggered. +if (window.bootstrapped) { + console.log('Loaded through window variable.'); + loadPage(); +} diff --git a/resources/assets/v2/pages/transactions/index.js b/resources/assets/v2/pages/transactions/index.js index cabf7e4698..6c1bde0793 100644 --- a/resources/assets/v2/pages/transactions/index.js +++ b/resources/assets/v2/pages/transactions/index.js @@ -44,12 +44,6 @@ const urlParts = window.location.href.split('/'); const type = urlParts[urlParts.length - 1]; ds.setType(type); -document.addEventListener('cellEditRequest', () => { - console.log('Loaded through event listener.'); - //loadPage(); -}); -let rowImmutableStore = []; - let dataTable; const editableFields = ['description', 'amount', 'date']; @@ -90,22 +84,18 @@ const onCellEditRequestMethod = (event) => { }; -document.addEventListener('cellValueChanged', () => { - console.log('I just realized a cell value has changed.'); -}); -document.addEventListener('onCellValueChanged', () => { - console.log('I just realized a cell value has changed.'); -}); - -let doOnCellValueChanged = function (e) { - console.log('I just realized a cell value has changed.'); -}; - const gridOptions = { rowModelType: 'infinite', datasource: ds, + cacheOverflowSize: 1, + cacheBlockSize: 20, onCellEditRequest: onCellEditRequestMethod, readOnlyEdit: true, + getRowId: function (params) { + console.log('getRowId', params.data.id); + return params.data.id; + }, + // Row Data: The data to be displayed. // rowData: [ // { description: "Tesla", model: "Model Y", price: 64950, electric: true }, diff --git a/resources/assets/v2/support/ag-grid/AccountDataSource.js b/resources/assets/v2/support/ag-grid/AccountDataSource.js new file mode 100644 index 0000000000..f608b0c077 --- /dev/null +++ b/resources/assets/v2/support/ag-grid/AccountDataSource.js @@ -0,0 +1,90 @@ +/* + * TransactionDataSource.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/. + */ + +import Get from "../../api/v2/model/account/get.js"; + +export default class AccountDataSource { + constructor() { + this.type = 'all'; + this.rowCount = null; + this.sortModel = null; + } + + + rowCount() { + console.log('The row count is: ', this.rowCount); + return this.rowCount; + } + + getRows(params) { + console.log('The sort model used is: ', params.sortModel); + let sorting = []; + + for (let i in params.sortModel) { + if (params.sortModel.hasOwnProperty(i)) { + let sort = params.sortModel[i]; + sorting.push({column: sort.colId, direction: sort.sort}); + } + } + + let getter = new Get(); + + getter.infiniteList({ + start_row: params.startRow, + end_row: params.endRow, + type: this.type, + sorting: sorting + }).then(response => { + this.parseAccounts(response.data.data, params.successCallback); + + // set meta data + this.rowCount = response.data.meta.pagination.total; + console.log('The row count is: ', this.rowCount); + }).catch(error => { + // todo this is auto generated + //this.notifications.wait.show = false; + //this.notifications.error.show = true; + //this.notifications.error.text = error.response.data.message; + console.log(error); + }); + } + + parseAccounts(data, callback) { + let accounts = []; + // no parse, just save + for (let i in data) { + if (data.hasOwnProperty(i)) { + let current = data[i]; + let entry = {}; + entry.id = current.id; + entry.name = current.attributes.name; + accounts.push(entry); + } + } + console.log('accounts length = ', accounts.length); + callback(accounts, false); + return accounts; + } + + setType(type) { + this.type = type; + } + +} diff --git a/resources/views/v2/accounts/index.blade.php b/resources/views/v2/accounts/index.blade.php new file mode 100644 index 0000000000..ef75465f77 --- /dev/null +++ b/resources/views/v2/accounts/index.blade.php @@ -0,0 +1,75 @@ +@extends('layout.v2') +@section('vite') + @vite(['resources/assets/v2/sass/app.scss', 'resources/assets/v2/pages/accounts/index.js']) +@endsection +@section('content') +
+
+ +
+
+
+
+

Info

+
+
+ some chart +
+
+
+
+
+
+

Info

+
+
+ Same +
+
+
+
+
+
+

Info

+
+
+ Same +
+
+
+
+
+
+ Nav +
+
+
+
+
+
+
+
+

Accounts

+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ Nav +
+
+
+
+ +@endsection diff --git a/routes/api.php b/routes/api.php index e93c70492c..b5006da2c4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -172,8 +172,19 @@ Route::group( } ); +// infinite accounts list: +Route::group( + [ + 'namespace' => 'FireflyIII\Api\V2\Controllers\Model\Account', + 'prefix' => 'v2/infinite/accounts', + 'as' => 'api.v2.infinite.accounts.', + ], + static function (): void { + Route::get('', ['uses' => 'IndexController@infiniteList', 'as' => 'list']); + } +); + // V2 API route for budgets and budget limits: -// TODO Validate from here down. Route::group( [ 'namespace' => 'FireflyIII\Api\V2\Controllers\Model', diff --git a/routes/web.php b/routes/web.php index 69a96d1ab8..6cf7792246 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1204,11 +1204,11 @@ Route::group( // show groups: // TODO improve these routes Route::get('{what}/all', ['uses' => 'Transaction\IndexController@indexAll', 'as' => 'index.all'])->where( - ['what' => 'withdrawal|deposit|transfers|transfer'] + ['what' => 'withdrawal|deposit|transfers|transfer|all'] ); Route::get('{what}/{start_date?}/{end_date?}', ['uses' => 'Transaction\IndexController@index', 'as' => 'index'])->where( - ['what' => 'withdrawal|deposit|transfers|transfer'] + ['what' => 'withdrawal|deposit|transfers|transfer|all'] )->where(['start_date' => DATEFORMAT]) ->where(['end_date' => DATEFORMAT]) ; diff --git a/vite.config.js b/vite.config.js index 5fb9219b06..cf0024a324 100644 --- a/vite.config.js +++ b/vite.config.js @@ -44,11 +44,15 @@ export default defineConfig({ 'resources/assets/v2/sass/app.scss', 'resources/assets/v2/pages/dashboard/dashboard.js', + // accounts + 'resources/assets/v2/pages/accounts/index.js', + // transactions 'resources/assets/v2/pages/transactions/create.js', 'resources/assets/v2/pages/transactions/edit.js', 'resources/assets/v2/pages/transactions/show.js', 'resources/assets/v2/pages/transactions/index.js', + ], refresh: true, }),