diff --git a/app/Generator/Chart/Account/AccountChartGeneratorInterface.php b/app/Generator/Chart/Account/AccountChartGeneratorInterface.php index 0cb57197da..13bd02b88d 100644 --- a/app/Generator/Chart/Account/AccountChartGeneratorInterface.php +++ b/app/Generator/Chart/Account/AccountChartGeneratorInterface.php @@ -24,6 +24,15 @@ use Illuminate\Support\Collection; */ interface AccountChartGeneratorInterface { + + /** + * @param array $values + * @param array $names + * + * @return array + */ + public function pieChart(array $values, array $names): array; + /** * @param Collection $accounts * @param Carbon $start diff --git a/app/Generator/Chart/Account/ChartJsAccountChartGenerator.php b/app/Generator/Chart/Account/ChartJsAccountChartGenerator.php index a8a222e16b..13253299bf 100644 --- a/app/Generator/Chart/Account/ChartJsAccountChartGenerator.php +++ b/app/Generator/Chart/Account/ChartJsAccountChartGenerator.php @@ -14,6 +14,7 @@ namespace FireflyIII\Generator\Chart\Account; use Carbon\Carbon; use FireflyIII\Models\Account; +use FireflyIII\Support\ChartColour; use Illuminate\Support\Collection; /** @@ -83,6 +84,37 @@ class ChartJsAccountChartGenerator implements AccountChartGeneratorInterface return $data; } + /** + * @param array $values + * @param array $names + * + * @return array + */ + public function pieChart(array $values, array $names): array + { + $data = [ + 'datasets' => [ + 0 => [], + ], + 'labels' => [], + ]; + $index = 0; + foreach ($values as $categoryId => $value) { + + // make larger than 0 + if (bccomp($value, '0') === -1) { + $value = bcmul($value, '-1'); + } + + $data['datasets'][0]['data'][] = round($value, 2); + $data['datasets'][0]['backgroundColor'][] = ChartColour::getColour($index); + $data['labels'][] = $names[$categoryId]; + $index++; + } + + return $data; + } + /** * @param Collection $accounts * @param Carbon $start diff --git a/app/Helpers/Collector/JournalCollector.php b/app/Helpers/Collector/JournalCollector.php index a3c0ad1397..47aab7b635 100644 --- a/app/Helpers/Collector/JournalCollector.php +++ b/app/Helpers/Collector/JournalCollector.php @@ -259,8 +259,10 @@ class JournalCollector implements JournalCollectorInterface $this->query->where( function (EloquentBuilder $q) use ($categoryIds) { - $q->whereIn('category_transaction.category_id', $categoryIds); - $q->orWhereIn('category_transaction_journal.category_id', $categoryIds); + if (count($categoryIds) > 0) { + $q->whereIn('category_transaction.category_id', $categoryIds); + $q->orWhereIn('category_transaction_journal.category_id', $categoryIds); + } } ); @@ -383,6 +385,27 @@ class JournalCollector implements JournalCollectorInterface return $this; } + /** + * @return JournalCollectorInterface + */ + public function withBudgetInformation(): JournalCollectorInterface + { + $this->joinBudgetTables(); + + return $this; + } + + /** + * @return JournalCollectorInterface + */ + public function withCategoryInformation(): JournalCollectorInterface + { + + $this->joinCategoryTables(); + + return $this; + } + /** * @return JournalCollectorInterface */ @@ -516,6 +539,8 @@ class JournalCollector implements JournalCollectorInterface $this->joinedBudget = true; $this->query->leftJoin('budget_transaction_journal', 'budget_transaction_journal.transaction_journal_id', '=', 'transaction_journals.id'); $this->query->leftJoin('budget_transaction', 'budget_transaction.transaction_id', '=', 'transactions.id'); + $this->fields[] = 'budget_transaction_journal.budget_id as transaction_journal_budget_id'; + $this->fields[] = 'budget_transaction.budget_id as transaction_budget_id'; } } diff --git a/app/Helpers/Collector/JournalCollectorInterface.php b/app/Helpers/Collector/JournalCollectorInterface.php index e857bdaadc..1b37f7eb81 100644 --- a/app/Helpers/Collector/JournalCollectorInterface.php +++ b/app/Helpers/Collector/JournalCollectorInterface.php @@ -131,6 +131,16 @@ interface JournalCollectorInterface */ public function setTypes(array $types): JournalCollectorInterface; + /** + * @return JournalCollectorInterface + */ + public function withBudgetInformation(): JournalCollectorInterface; + + /** + * @return JournalCollectorInterface + */ + public function withCategoryInformation(): JournalCollectorInterface; + /** * @return JournalCollectorInterface */ diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index fca6f6f678..c1c89ef897 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -17,6 +17,7 @@ use Carbon\Carbon; use ExpandedForm; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Helpers\Collector\JournalCollector; +use FireflyIII\Helpers\Collector\JournalCollectorInterface; use FireflyIII\Http\Requests\AccountFormRequest; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; @@ -209,7 +210,7 @@ class AccountController extends Controller * * @return View */ - public function show(AccountTaskerInterface $tasker, ARI $repository, Account $account) + public function show(JournalCollectorInterface $collector, Account $account) { if ($account->accountType->type === AccountType::INITIAL_BALANCE) { return $this->redirectToOriginalAccount($account); @@ -218,62 +219,20 @@ class AccountController extends Controller $subTitleIcon = config('firefly.subIconsByIdentifier.' . $account->accountType->type); $subTitle = $account->name; $range = Preferences::get('viewRange', '1M')->data; - /** @var Carbon $start */ - $start = session('start', Navigation::startOfPeriod(new Carbon, $range)); - /** @var Carbon $end */ - $end = session('end', Navigation::endOfPeriod(new Carbon, $range)); - $page = intval(Input::get('page')) === 0 ? 1 : intval(Input::get('page')); - $pageSize = intval(Preferences::get('transactionPageSize', 50)->data); + $start = session('start', Navigation::startOfPeriod(new Carbon, $range)); + $end = session('end', Navigation::endOfPeriod(new Carbon, $range)); + $page = intval(Input::get('page')) === 0 ? 1 : intval(Input::get('page')); + $pageSize = intval(Preferences::get('transactionPageSize', 50)->data); - // replace with journal collector: - $collector = new JournalCollector(auth()->user()); + // grab those journals: $collector->setAccounts(new Collection([$account]))->setRange($start, $end)->setLimit($pageSize)->setPage($page); $journals = $collector->getPaginatedJournals(); $journals->setPath('accounts/show/' . $account->id); - // grouped other months thing: - // oldest transaction in account: - $start = $repository->oldestJournalDate($account); - $range = Preferences::get('viewRange', '1M')->data; - $start = Navigation::startOfPeriod($start, $range); - $end = Navigation::endOfX(new Carbon, $range); - $entries = new Collection; + // generate entries for each period (and cache those) + $entries = $this->periodEntries($account); - // chart properties for cache: - $cache = new CacheProperties; - $cache->addProperty($start); - $cache->addProperty($end); - $cache->addProperty('account-show'); - $cache->addProperty($account->id); - - - if ($cache->has()) { - $entries = $cache->get(); - Log::debug('Entries are cached, return cache.'); - - return view('accounts.show', compact('account', 'what', 'entries', 'subTitleIcon', 'journals', 'subTitle')); - } - - // only include asset accounts when this account is an asset: - $assets = new Collection; - if (in_array($account->accountType->type, [AccountType::ASSET, AccountType::DEFAULT])) { - $assets = $repository->getAccountsByType([AccountType::ASSET, AccountType::DEFAULT]); - } - Log::debug('Going to get period expenses and incomes.'); - while ($end >= $start) { - $end = Navigation::startOfPeriod($end, $range); - $currentEnd = Navigation::endOfPeriod($end, $range); - $spent = $tasker->amountOutInPeriod(new Collection([$account]), $assets, $end, $currentEnd); - $earned = $tasker->amountInInPeriod(new Collection([$account]), $assets, $end, $currentEnd); - $dateStr = $end->format('Y-m-d'); - $dateName = Navigation::periodShow($end, $range); - $entries->push([$dateStr, $dateName, $spent, $earned]); - $end = Navigation::subtractPeriod($end, $range, 1); - - } - $cache->store($entries); - - return view('accounts.show', compact('account', 'what', 'entries', 'subTitleIcon', 'journals', 'subTitle')); + return view('accounts.show', compact('account', 'what', 'entries', 'subTitleIcon', 'journals', 'subTitle', 'start', 'end')); } /** @@ -318,7 +277,7 @@ class AccountController extends Controller $journals = $collector->getPaginatedJournals(); $journals->setPath('accounts/show/' . $account->id . '/' . $date); - return view('accounts.show_with_date', compact('category', 'date', 'account', 'journals', 'subTitle', 'carbon')); + return view('accounts.show_with_date', compact('category', 'date', 'account', 'journals', 'subTitle', 'carbon', 'start', 'end')); } /** @@ -397,6 +356,63 @@ class AccountController extends Controller return ''; } + /** + * This method returns "period entries", so nov-2015, dec-2015, etc etc (this depends on the users session range) + * and for each period, the amount of money spent and earned. This is a complex operation which is cached for + * performance reasons. + * + * @param Account $account The account involved. + * + * @return Collection + */ + private function periodEntries(Account $account): Collection + { + /** @var ARI $repository */ + $repository = app(ARI::class); + /** @var AccountTaskerInterface $tasker */ + $tasker = app(AccountTaskerInterface::class); + + $start = $repository->oldestJournalDate($account); + $range = Preferences::get('viewRange', '1M')->data; + $start = Navigation::startOfPeriod($start, $range); + $end = Navigation::endOfX(new Carbon, $range); + $entries = new Collection; + + // properties for cache + $cache = new CacheProperties; + $cache->addProperty($start); + $cache->addProperty($end); + $cache->addProperty('account-show-period-entries'); + $cache->addProperty($account->id); + + if ($cache->has()) { + Log::debug('Entries are cached, return cache.'); + + return $cache->get(); + } + + // only include asset accounts when this account is an asset: + $assets = new Collection; + if (in_array($account->accountType->type, [AccountType::ASSET, AccountType::DEFAULT])) { + $assets = $repository->getAccountsByType([AccountType::ASSET, AccountType::DEFAULT]); + } + Log::debug('Going to get period expenses and incomes.'); + while ($end >= $start) { + $end = Navigation::startOfPeriod($end, $range); + $currentEnd = Navigation::endOfPeriod($end, $range); + $spent = $tasker->amountOutInPeriod(new Collection([$account]), $assets, $end, $currentEnd); + $earned = $tasker->amountInInPeriod(new Collection([$account]), $assets, $end, $currentEnd); + $dateStr = $end->format('Y-m-d'); + $dateName = Navigation::periodShow($end, $range); + $entries->push([$dateStr, $dateName, $spent, $earned]); + $end = Navigation::subtractPeriod($end, $range, 1); + + } + $cache->store($entries); + + return $entries; + } + /** * @param Account $account * diff --git a/app/Http/Controllers/Chart/AccountController.php b/app/Http/Controllers/Chart/AccountController.php index 4ddca05bce..1b21d83905 100644 --- a/app/Http/Controllers/Chart/AccountController.php +++ b/app/Http/Controllers/Chart/AccountController.php @@ -17,10 +17,15 @@ use Carbon\Carbon; use Exception; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Generator\Chart\Account\AccountChartGeneratorInterface; +use FireflyIII\Helpers\Collector\JournalCollectorInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; +use FireflyIII\Models\Transaction; +use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; +use FireflyIII\Repositories\Category\CategoryRepositoryInterface; use FireflyIII\Support\CacheProperties; use Illuminate\Support\Collection; use Log; @@ -99,6 +104,87 @@ class AccountController extends Controller return Response::json($data); } + /** + * @param JournalCollectorInterface $collector + * @param Account $account + * @param Carbon $start + * @param Carbon $end + * + * @return \Illuminate\Http\JsonResponse + */ + public function expenseByBudget(JournalCollectorInterface $collector, Account $account, Carbon $start, Carbon $end) + { + $cache = new CacheProperties; + $cache->addProperty($account->id); + $cache->addProperty($start); + $cache->addProperty($end); + $cache->addProperty('expenseByBudget'); + if ($cache->has()) { + return Response::json($cache->get()); + } + + + // grab all journals: + $collector->setAccounts(new Collection([$account]))->setRange($start, $end)->withBudgetInformation()->setTypes([TransactionType::WITHDRAWAL]); + $transactions = $collector->getJournals(); + + $result = []; + /** @var Transaction $transaction */ + foreach ($transactions as $transaction) { + $jrnlBudgetId = intval($transaction->transaction_journal_budget_id); + $transBudgetId = intval($transaction->transaction_budget_id); + $budgetId = max($jrnlBudgetId, $transBudgetId); + + $result[$budgetId] = $result[$budgetId] ?? '0'; + $result[$budgetId] = bcadd($transaction->transaction_amount, $result[$budgetId]); + } + $names = $this->getBudgetNames(array_keys($result)); + $data = $this->generator->pieChart($result, $names); + $cache->store($data); + + return Response::json($data); + } + + /** + * @param JournalCollectorInterface $collector + * @param Account $account + * @param Carbon $start + * @param Carbon $end + * + * @return \Illuminate\Http\JsonResponse + */ + public function expenseByCategory(JournalCollectorInterface $collector, Account $account, Carbon $start, Carbon $end) + { + $cache = new CacheProperties; + $cache->addProperty($account->id); + $cache->addProperty($start); + $cache->addProperty($end); + $cache->addProperty('expenseByCategory'); + if ($cache->has()) { + return Response::json($cache->get()); + } + + // grab all journals: + $collector->setAccounts(new Collection([$account]))->setRange($start, $end)->withCategoryInformation()->setTypes([TransactionType::WITHDRAWAL]); + $transactions = $collector->getJournals(); + $result = []; + /** @var Transaction $transaction */ + foreach ($transactions as $transaction) { + $jrnlCatId = intval($transaction->transaction_journal_category_id); + $transCatId = intval($transaction->transaction_category_id); + $categoryId = max($jrnlCatId, $transCatId); + + $result[$categoryId] = $result[$categoryId] ?? '0'; + $result[$categoryId] = bcadd($transaction->transaction_amount, $result[$categoryId]); + } + $names = $this->getCategoryNames(array_keys($result)); + $data = $this->generator->pieChart($result, $names); + $cache->store($data); + + return Response::json($data); + + } + /** * Shows the balances for all the user's frontpage accounts. * @@ -116,6 +202,43 @@ class AccountController extends Controller return Response::json($this->accountBalanceChart($start, $end, $accounts)); } + /** + * @param Account $account + * @param Carbon $start + * @param Carbon $end + */ + public function incomeByCategory(JournalCollectorInterface $collector, Account $account, Carbon $start, Carbon $end) + { + $cache = new CacheProperties; + $cache->addProperty($account->id); + $cache->addProperty($start); + $cache->addProperty($end); + $cache->addProperty('incomeByCategory'); + if ($cache->has()) { + return Response::json($cache->get()); + } + + // grab all journals: + $collector->setAccounts(new Collection([$account]))->setRange($start, $end)->withCategoryInformation()->setTypes([TransactionType::DEPOSIT]); + $transactions = $collector->getJournals(); + $result = []; + /** @var Transaction $transaction */ + foreach ($transactions as $transaction) { + $jrnlCatId = intval($transaction->transaction_journal_category_id); + $transCatId = intval($transaction->transaction_category_id); + $categoryId = max($jrnlCatId, $transCatId); + + $result[$categoryId] = $result[$categoryId] ?? '0'; + $result[$categoryId] = bcadd($transaction->transaction_amount, $result[$categoryId]); + } + $names = $this->getCategoryNames(array_keys($result)); + $data = $this->generator->pieChart($result, $names); + $cache->store($data); + + return Response::json($data); + + } + /** * Shows the balances for a given set of dates and accounts. * @@ -227,7 +350,6 @@ class AccountController extends Controller return Response::json($data); } - /** * @param Account $account * @param string $date @@ -319,4 +441,51 @@ class AccountController extends Controller return $data; } + /** + * @param array $budgetIds + * + * @return array + */ + private function getBudgetNames(array $budgetIds): array + { + + /** @var BudgetRepositoryInterface $repository */ + $repository = app(BudgetRepositoryInterface::class); + $budgets = $repository->getBudgets(); + $grouped = $budgets->groupBy('id')->toArray(); + $return = []; + foreach ($budgetIds as $budgetId) { + if (isset($grouped[$budgetId])) { + $return[$budgetId] = $grouped[$budgetId][0]['name']; + } + } + $return[0] = trans('firefly.no_budget'); + + return $return; + } + + /** + * Small helper function for some of the charts. + * + * @param array $categoryIds + * + * @return array + */ + private function getCategoryNames(array $categoryIds): array + { + /** @var CategoryRepositoryInterface $repository */ + $repository = app(CategoryRepositoryInterface::class); + $categories = $repository->getCategories(); + $grouped = $categories->groupBy('id')->toArray(); + $return = []; + foreach ($categoryIds as $categoryId) { + if (isset($grouped[$categoryId])) { + $return[$categoryId] = $grouped[$categoryId][0]['name']; + } + } + $return[0] = trans('firefly.noCategory'); + + return $return; + } + } diff --git a/public/js/ff/accounts/show.js b/public/js/ff/accounts/show.js index f913802ab3..0bfb8e9a69 100644 --- a/public/js/ff/accounts/show.js +++ b/public/js/ff/accounts/show.js @@ -1,4 +1,4 @@ -/* global $, lineChart, accountID, token */ +/* global $, lineChart, accountID, token, incomeByCategoryUri, expenseByCategoryUri, expenseByBudgetUri */ // Return a helper with preserved width of cells @@ -15,10 +15,11 @@ var fixHelper = function (e, tr) { $(function () { "use strict"; - if (typeof(lineChart) === "function" && typeof accountID !== 'undefined') { - lineChart('chart/account/' + accountID, 'overview-chart'); - } + pieChart(incomeByCategoryUri, 'account-cat-in'); + pieChart(expenseByCategoryUri, 'account-cat-out'); + pieChart(expenseByBudgetUri, 'account-budget-out'); + // sortable! if (typeof $(".sortable-table tbody").sortable !== "undefined") { diff --git a/public/js/ff/accounts/show_with_date.js b/public/js/ff/accounts/show_with_date.js index 8ad1376312..c34d4ce952 100644 --- a/public/js/ff/accounts/show_with_date.js +++ b/public/js/ff/accounts/show_with_date.js @@ -6,7 +6,7 @@ * of the MIT license. See the LICENSE file for details. */ -/* global $, lineChart, dateString, accountID, token */ +/* global $, lineChart, dateString, accountID, token, incomeByCategoryUri, expenseByCategoryUri, expenseByBudgetUri */ // Return a helper with preserved width of cells @@ -23,13 +23,13 @@ var fixHelper = function (e, tr) { $(function () { "use strict"; - if (typeof(lineChart) === "function" && - typeof accountID !== 'undefined' && - typeof dateString !== 'undefined' - ) { - lineChart('chart/account/' + accountID + '/' + dateString, 'period-specific-account'); - } + lineChart('chart/account/' + accountID + '/' + dateString, 'period-specific-account'); + + pieChart(incomeByCategoryUri, 'account-cat-in'); + pieChart(expenseByCategoryUri, 'account-cat-out'); + pieChart(expenseByBudgetUri, 'account-budget-out'); + // sortable! if (typeof $(".sortable-table tbody").sortable !== "undefined") { diff --git a/resources/lang/en_US/firefly.php b/resources/lang/en_US/firefly.php index 2befbc056f..665e43838f 100644 --- a/resources/lang/en_US/firefly.php +++ b/resources/lang/en_US/firefly.php @@ -87,6 +87,9 @@ return [ 'need_more_help' => 'If you need more help using Firefly III, please open a ticker on Github.', 'nothing_to_display' => 'There are no transactions to show you', 'show_all_no_filter' => 'Show all transactions without grouping them by date.', + 'expenses_by_category' => 'Expenses by category', + 'expenses_by_budget' => 'Expenses by budget', + 'income_by_category' => 'Income by category', // repeat frequencies: 'repeat_freq_yearly' => 'yearly', diff --git a/resources/views/accounts/show.twig b/resources/views/accounts/show.twig index 280f881978..28f1b770b0 100644 --- a/resources/views/accounts/show.twig +++ b/resources/views/accounts/show.twig @@ -28,7 +28,44 @@ - +