diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index ed24ac44..9d030c82 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -153,31 +153,35 @@ class CashflowAnalyticsController extends Controller private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int { return Transaction::query() - ->where('user_id', $userId) - ->whereBetween('transaction_date', [$from, $to]) + ->where('transactions.user_id', $userId) + ->whereBetween('transactions.transaction_date', [$from, $to]) ->where(function ($q) use ($type) { - $q->whereHas('category', function ($q) use ($type) { - $q->where('type', $type); + $q->whereExists(function ($sub) use ($type) { + $sub->select(DB::raw(1)) + ->from('categories') + ->whereColumn('categories.id', 'transactions.category_id') + ->where('categories.type', $type); }) ->orWhere(function ($q) use ($type) { - $q->whereNull('category_id') - ->where('amount', $type === CategoryType::Income ? '>' : '<', 0); + $q->whereNull('transactions.category_id') + ->where('transactions.amount', $type === CategoryType::Income ? '>' : '<', 0); }); }) - ->sum('amount'); + ->sum('transactions.amount'); } private function getCategoryBreakdown(string $userId, Carbon $from, Carbon $to, CategoryType $type) { // Get categorized transactions $categorized = Transaction::query() - ->where('user_id', $userId) - ->whereBetween('transaction_date', [$from, $to]) - ->whereHas('category', function ($q) use ($type) { - $q->where('type', $type); + ->where('transactions.user_id', $userId) + ->whereBetween('transactions.transaction_date', [$from, $to]) + ->join('categories', function ($join) use ($type) { + $join->on('transactions.category_id', '=', 'categories.id') + ->where('categories.type', '=', $type); }) - ->select('category_id', DB::raw('sum(amount) as total_amount')) - ->groupBy('category_id') + ->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount')) + ->groupBy('transactions.category_id') ->with('category') ->get() ->map(function ($item) { diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index 9328233e..f89fa5cc 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -369,13 +369,14 @@ class DashboardAnalyticsController extends Controller private function getCategorySpending(string $userId, Carbon $from, Carbon $to) { return Transaction::query() - ->where('user_id', $userId) - ->whereBetween('transaction_date', [$from, $to]) - ->whereHas('category', function ($q) { - $q->where('type', CategoryType::Expense); + ->where('transactions.user_id', $userId) + ->whereBetween('transactions.transaction_date', [$from, $to]) + ->join('categories', function ($join) { + $join->on('transactions.category_id', '=', 'categories.id') + ->where('categories.type', '=', CategoryType::Expense); }) - ->select('category_id', DB::raw('sum(amount) as total_amount')) - ->groupBy('category_id') + ->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount')) + ->groupBy('transactions.category_id') ->with('category') ->get() ->map(function ($item) { @@ -444,12 +445,13 @@ class DashboardAnalyticsController extends Controller private function calculateSpending(Carbon $from, Carbon $to): int { $spending = Transaction::query() - ->where('user_id', request()->user()->id) - ->whereBetween('transaction_date', [$from, $to]) - ->whereHas('category', function ($q) { - $q->where('type', CategoryType::Expense); + ->where('transactions.user_id', request()->user()->id) + ->whereBetween('transactions.transaction_date', [$from, $to]) + ->join('categories', function ($join) { + $join->on('transactions.category_id', '=', 'categories.id') + ->where('categories.type', '=', CategoryType::Expense); }) - ->sum('amount'); + ->sum('transactions.amount'); return abs($spending); } @@ -457,20 +459,22 @@ class DashboardAnalyticsController extends Controller private function calculateCashFlow(Carbon $from, Carbon $to): array { $income = Transaction::query() - ->where('user_id', request()->user()->id) - ->whereBetween('transaction_date', [$from, $to]) - ->whereHas('category', function ($q) { - $q->where('type', CategoryType::Income); + ->where('transactions.user_id', request()->user()->id) + ->whereBetween('transactions.transaction_date', [$from, $to]) + ->join('categories', function ($join) { + $join->on('transactions.category_id', '=', 'categories.id') + ->where('categories.type', '=', CategoryType::Income); }) - ->sum('amount'); + ->sum('transactions.amount'); $expense = Transaction::query() - ->where('user_id', request()->user()->id) - ->whereBetween('transaction_date', [$from, $to]) - ->whereHas('category', function ($q) { - $q->where('type', CategoryType::Expense); + ->where('transactions.user_id', request()->user()->id) + ->whereBetween('transactions.transaction_date', [$from, $to]) + ->join('categories', function ($join) { + $join->on('transactions.category_id', '=', 'categories.id') + ->where('categories.type', '=', CategoryType::Expense); }) - ->sum('amount'); + ->sum('transactions.amount'); return [ 'income' => $income, diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 6265e635..e5adf379 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -2,43 +2,232 @@ namespace App\Http\Controllers; +use App\Enums\CategoryType; use App\Models\Account; -use App\Models\Bank; -use App\Models\Category; +use App\Models\Transaction; +use App\Services\BalanceLookup; +use App\Services\ExchangeRateService; +use App\Services\PeriodComparator; +use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; class DashboardController extends Controller { + public function __construct(private ExchangeRateService $exchangeRateService) {} + public function __invoke(Request $request): Response { - $user = $request->user(); + return Inertia::render('dashboard', [ + 'showEncryptionPrompt' => session('show_encryption_prompt', false), + 'netWorthEvolution' => Inertia::defer(fn () => $this->getNetWorthEvolution($request), 'dashboard'), + 'topCategories' => Inertia::defer(fn () => $this->getTopCategories($request), 'dashboard'), + 'cashflowSummary' => Inertia::defer(fn () => $this->getCashflowSummary($request), 'dashboard'), + ]); + } - $categories = Category::query() - ->where('user_id', $user->id) - ->orderBy('name') - ->get(['id', 'name', 'icon', 'color']); + private function getNetWorthEvolution(Request $request): array + { + $user = $request->user(); + $now = Carbon::now(); + $start = $now->copy()->subMonths(12); + $end = $now->copy(); + + $userCurrency = $user->currency_code; $accounts = Account::query() ->where('user_id', $user->id) - ->with('bank:id,name,logo') - ->orderBy('name') - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']); + ->with(['bank:id,name,logo']) + ->get(); - $banks = Bank::query() - ->where(function ($q) use ($user) { - $q->whereNull('user_id') - ->orWhere('user_id', $user->id); + $accountIds = $accounts->pluck('id'); + + $lookupEnd = Carbon::now()->gt($end) ? Carbon::now() : $end->copy(); + $lookup = BalanceLookup::forAccounts($accountIds, $start->copy()->startOfMonth(), $lookupEnd); + + $points = []; + $current = $start->copy()->startOfMonth(); + $endMonth = $end->copy()->startOfMonth(); + + while ($current->lte($endMonth)) { + $date = $current->copy()->endOfMonth(); + $point = [ + 'month' => $date->format('Y-m'), + 'timestamp' => $date->timestamp, + ]; + + foreach ($accounts as $account) { + $originalBalance = $lookup->getBalanceAt($account->id, $date); + $convertedBalance = $this->convertBalance( + $originalBalance, + $account->currency_code, + $userCurrency, + $date->toDateString(), + ); + + $point[$account->id] = $convertedBalance; + + if ($account->currency_code !== $userCurrency) { + $point[$account->id.'_original'] = [ + 'amount' => $originalBalance, + 'currency_code' => $account->currency_code, + ]; + } + + if ($account->type->supportsInvestedAmount()) { + $investedAmount = $lookup->getInvestedAmountAt($account->id, $date); + $point[$account->id.'_invested'] = $investedAmount !== null + ? $this->convertBalance($investedAmount, $account->currency_code, $userCurrency, $date->toDateString()) + : null; + } + } + + $points[] = $point; + $current->addMonth(); + } + + $accountsConfig = $accounts->mapWithKeys(function ($account) use ($userCurrency, $lookup, $now) { + $config = [ + 'id' => $account->id, + 'name' => $account->name, + 'name_iv' => $account->name_iv, + 'encrypted' => $account->encrypted, + 'type' => $account->type, + 'currency_code' => $account->currency_code, + 'bank' => $account->bank, + ]; + + if ($account->type->supportsInvestedAmount()) { + $investedAmount = $lookup->getInvestedAmountAt($account->id, $now); + $config['invested_amount'] = $investedAmount !== null + ? $this->convertBalance($investedAmount, $account->currency_code, $userCurrency, $now->toDateString()) + : null; + } + + return [$account->id => $config]; + }); + + return [ + 'data' => $points, + 'accounts' => $accountsConfig, + 'currency_code' => $userCurrency, + ]; + } + + private function getTopCategories(Request $request): array + { + $user = $request->user(); + $now = Carbon::now(); + $from = $now->copy()->subDays(30); + $to = $now->copy(); + + $period = new PeriodComparator($from, $to); + $previousPeriod = $period->previous(); + + $currentSpending = $this->getCategorySpending($user->id, $period->from, $period->to); + $previousSpending = $this->getCategorySpending($user->id, $previousPeriod->from, $previousPeriod->to); + + $totalAmount = $currentSpending->sum('amount'); + + return $currentSpending + ->sortByDesc('amount') + ->take(10) + ->map(function ($item) use ($previousSpending, $totalAmount) { + $previousAmount = $previousSpending->firstWhere('category_id', $item['category_id'])['amount'] ?? 0; + + return [ + 'category' => $item['category'], + 'amount' => $item['amount'], + 'previous_amount' => $previousAmount, + 'total_amount' => $totalAmount, + ]; }) - ->orderBy('name') - ->get(['id', 'name', 'logo']); + ->values() + ->all(); + } - return Inertia::render('dashboard', [ - 'categories' => $categories, - 'accounts' => $accounts, - 'banks' => $banks, - 'showEncryptionPrompt' => session('show_encryption_prompt', false), - ]); + private function getCashflowSummary(Request $request): array + { + $user = $request->user(); + $now = Carbon::now(); + $from = $now->copy()->startOfMonth(); + $to = $now->copy()->endOfMonth(); + + $period = new PeriodComparator($from, $to); + $previousPeriod = $period->previous(); + + return [ + 'current' => $this->calculateCashflowSummary($user->id, $period->from, $period->to), + 'previous' => $this->calculateCashflowSummary($user->id, $previousPeriod->from, $previousPeriod->to), + ]; + } + + private function getCategorySpending(string $userId, Carbon $from, Carbon $to) + { + return Transaction::query() + ->where('transactions.user_id', $userId) + ->whereBetween('transactions.transaction_date', [$from, $to]) + ->join('categories', function ($join) { + $join->on('transactions.category_id', '=', 'categories.id') + ->where('categories.type', '=', CategoryType::Expense); + }) + ->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount')) + ->groupBy('transactions.category_id') + ->with('category') + ->get() + ->map(function ($item) { + return [ + 'category_id' => $item->category_id, + 'category' => $item->category, + 'amount' => abs($item->total_amount), + ]; + }); + } + + private function calculateCashflowSummary(string $userId, Carbon $from, Carbon $to): array + { + $income = $this->getTransactionSum($userId, $from, $to, CategoryType::Income); + $expense = abs($this->getTransactionSum($userId, $from, $to, CategoryType::Expense)); + + $net = $income - $expense; + $savingsRate = $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0; + + return [ + 'income' => $income, + 'expense' => $expense, + 'net' => $net, + 'savings_rate' => $savingsRate, + ]; + } + + private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int + { + return Transaction::query() + ->where('transactions.user_id', $userId) + ->whereBetween('transactions.transaction_date', [$from, $to]) + ->where(function ($q) use ($type) { + $q->whereExists(function ($sub) use ($type) { + $sub->select(DB::raw(1)) + ->from('categories') + ->whereColumn('categories.id', 'transactions.category_id') + ->where('categories.type', $type); + }) + ->orWhere(function ($q) use ($type) { + $q->whereNull('transactions.category_id') + ->where('transactions.amount', $type === CategoryType::Income ? '>' : '<', 0); + }); + }) + ->sum('transactions.amount'); + } + + private function convertBalance(int $balance, string $sourceCurrency, string $targetCurrency, string $date): int + { + if (strtolower($sourceCurrency) === strtolower($targetCurrency)) { + return $balance; + } + + return $this->exchangeRateService->convert($sourceCurrency, $targetCurrency, $balance, $date); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index b64b21ea..95890df4 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -43,12 +43,15 @@ class HandleInertiaRequests extends Middleware $isDemoAccount = $user?->isDemoAccount() && ! app()->environment('local') ?? false; $isDemoQuery = $request->query('demo') === '1'; + // Cache encryption checks to avoid duplicate queries + $hasEncryptedAccounts = $user?->accounts()->where('encrypted', true)->exists() ?? false; + $hasEncryptedTransactions = $user?->transactions() + ->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv')) + ->exists() ?? false; + // Clean up encryption data if no encrypted accounts or transactions remain if (! $request->is('api/*') && $user?->encryption_salt !== null) { - $hasAnyEncryptedData = $user->accounts()->where('encrypted', true)->exists() - || $user->transactions()->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv'))->exists(); - - if (! $hasAnyEncryptedData) { + if (! $hasEncryptedAccounts && ! $hasEncryptedTransactions) { $user->encryptedMessage()->delete(); $user->update(['encryption_salt' => null]); } @@ -109,11 +112,9 @@ class HandleInertiaRequests extends Middleware 'labels' => fn () => $user ? $user->labels() ->orderBy('name') ->get(['id', 'name', 'color']) : [], - 'hasEncryptedAccounts' => $user?->accounts()->where('encrypted', true)->exists() ?? false, + 'hasEncryptedAccounts' => $hasEncryptedAccounts, 'hasEncryptionSetup' => $user?->encryption_salt !== null, - 'hasEncryptedTransactions' => $user?->transactions() - ->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv')) - ->exists() ?? false, + 'hasEncryptedTransactions' => $hasEncryptedTransactions, 'locale' => app()->getLocale(), 'translations' => $this->getTranslations(), ]; diff --git a/app/Services/BalanceLookup.php b/app/Services/BalanceLookup.php index 3dddff88..3df17391 100644 --- a/app/Services/BalanceLookup.php +++ b/app/Services/BalanceLookup.php @@ -27,9 +27,10 @@ class BalanceLookup /** * Preload all balance data for a set of accounts covering the given date range. * - * Executes exactly 2 queries: - * 1. The most recent balance record before the range start for each account (carry-forward seeds). - * 2. All balance records within the range. + * Executes exactly 3 efficient queries (no correlated subqueries): + * 1. A derived-table join to find the latest balance record before the range start per account. + * 2. A derived-table join to find the latest non-null invested_amount before the range start per account. + * 3. All balance records within the range. * * @param Collection|array $accountIds */ @@ -46,32 +47,40 @@ class BalanceLookup $endDate = $rangeEnd->toDateString(); // Query 1: Get the latest balance record before the range start for each account. - // Uses a correlated subquery to find the max balance_date < rangeStart per account. + // Uses a derived table with GROUP BY + MAX() joined back, avoiding correlated subquery. $carryForwardRecords = AccountBalance::query() - ->whereIn('account_id', $accountIdList) - ->where('balance_date', '<', $startDate) - ->whereRaw('balance_date = ( - SELECT MAX(ab2.balance_date) - FROM account_balances ab2 - WHERE ab2.account_id = account_balances.account_id - AND ab2.balance_date < ? - )', [$startDate]) - ->get(['account_id', 'balance_date', 'balance', 'invested_amount']); + ->whereIn('account_balances.account_id', $accountIdList) + ->joinSub( + AccountBalance::query() + ->selectRaw('account_id, MAX(balance_date) as max_date') + ->whereIn('account_id', $accountIdList) + ->where('balance_date', '<', $startDate) + ->groupBy('account_id'), + 'latest', + function ($join) { + $join->on('account_balances.account_id', '=', 'latest.account_id') + ->on('account_balances.balance_date', '=', 'latest.max_date'); + } + ) + ->get(['account_balances.account_id', 'account_balances.balance_date', 'account_balances.balance', 'account_balances.invested_amount']); - // For invested_amount carry-forward, we also need the latest non-null invested_amount - // before the range start, which might be on a different date than the latest balance. + // Query 2: Get the latest non-null invested_amount before the range start for each account. $investedCarryForwardRecords = AccountBalance::query() - ->whereIn('account_id', $accountIdList) - ->where('balance_date', '<', $startDate) - ->whereNotNull('invested_amount') - ->whereRaw('balance_date = ( - SELECT MAX(ab3.balance_date) - FROM account_balances ab3 - WHERE ab3.account_id = account_balances.account_id - AND ab3.balance_date < ? - AND ab3.invested_amount IS NOT NULL - )', [$startDate]) - ->get(['account_id', 'balance_date', 'invested_amount']); + ->whereIn('account_balances.account_id', $accountIdList) + ->joinSub( + AccountBalance::query() + ->selectRaw('account_id, MAX(balance_date) as max_date') + ->whereIn('account_id', $accountIdList) + ->where('balance_date', '<', $startDate) + ->whereNotNull('invested_amount') + ->groupBy('account_id'), + 'latest_invested', + function ($join) { + $join->on('account_balances.account_id', '=', 'latest_invested.account_id') + ->on('account_balances.balance_date', '=', 'latest_invested.max_date'); + } + ) + ->get(['account_balances.account_id', 'account_balances.balance_date', 'account_balances.invested_amount']); // Query 2: All balance records within the range. $rangeRecords = AccountBalance::query() diff --git a/resources/js/components/dashboard/cashflow-summary-card.tsx b/resources/js/components/dashboard/cashflow-summary-card.tsx index 4d045410..796c4d39 100644 --- a/resources/js/components/dashboard/cashflow-summary-card.tsx +++ b/resources/js/components/dashboard/cashflow-summary-card.tsx @@ -11,9 +11,7 @@ import { cashflow } from '@/routes'; import { SharedData } from '@/types'; import { __ } from '@/utils/i18n'; import { Link, usePage } from '@inertiajs/react'; -import { endOfMonth, format, startOfMonth } from 'date-fns'; import { ArrowRight, TrendingDown, TrendingUp } from 'lucide-react'; -import { useEffect, useState } from 'react'; interface CashflowSummary { income: number; @@ -23,42 +21,20 @@ interface CashflowSummary { } interface CashflowSummaryCardProps { + data?: { + current: CashflowSummary; + previous: CashflowSummary; + } | null; loading?: boolean; } -export function CashflowSummaryCard({ loading }: CashflowSummaryCardProps) { +export function CashflowSummaryCard({ + data, + loading, +}: CashflowSummaryCardProps) { const { auth } = usePage().props; - const [data, setData] = useState<{ - current: CashflowSummary; - previous: CashflowSummary; - } | null>(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const fetchData = async () => { - try { - const now = new Date(); - const from = format(startOfMonth(now), 'yyyy-MM-dd'); - const to = format(endOfMonth(now), 'yyyy-MM-dd'); - const params = new URLSearchParams({ from, to }); - - const response = await fetch( - `/api/cashflow/summary?${params.toString()}`, - ); - const result = await response.json(); - setData(result); - } catch (error) { - console.error('Failed to fetch cashflow summary:', error); - } finally { - setIsLoading(false); - } - }; - - fetchData(); - }, []); - - if (!auth?.user || isLoading || loading) { + if (!auth?.user || loading || !data) { return ( @@ -78,10 +54,6 @@ export function CashflowSummaryCard({ loading }: CashflowSummaryCardProps) { ); } - if (!data) { - return null; - } - const { current } = data; const isPositiveNet = current.net >= 0; diff --git a/resources/js/hooks/use-dashboard-data.ts b/resources/js/hooks/use-dashboard-data.ts index b717cbeb..e2aaaca9 100644 --- a/resources/js/hooks/use-dashboard-data.ts +++ b/resources/js/hooks/use-dashboard-data.ts @@ -49,7 +49,7 @@ export interface DashboardData { isLoading: boolean; } -function deriveAccountMetrics( +export function deriveAccountMetrics( netWorthEvolution: NetWorthEvolutionData, ): AccountWithMetrics[] { const { data, accounts } = netWorthEvolution; diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 4bb1ec2a..e2ac8069 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -5,31 +5,65 @@ import { TopCategoriesCard } from '@/components/dashboard/top-categories-card'; import HeadingSmall from '@/components/heading-small'; import UnlockMessageDialog from '@/components/unlock-message-dialog'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; -import { useDashboardData } from '@/hooks/use-dashboard-data'; +import { + type NetWorthEvolutionData, + deriveAccountMetrics, +} from '@/hooks/use-dashboard-data'; import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; import { dashboard } from '@/routes'; import { BreadcrumbItem, SharedData } from '@/types'; +import { Category } from '@/types/category'; import { __ } from '@/utils/i18n'; -import { Head, usePage } from '@inertiajs/react'; -import { useEffect, useState } from 'react'; +import { Deferred, Head, router, usePage } from '@inertiajs/react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +interface CashflowSummary { + income: number; + expense: number; + net: number; + savings_rate: number; +} interface DashboardProps extends SharedData { showEncryptionPrompt: boolean; + netWorthEvolution?: NetWorthEvolutionData; + topCategories?: Array<{ + category: Category; + amount: number; + previous_amount: number; + total_amount: number; + }>; + cashflowSummary?: { + current: CashflowSummary; + previous: CashflowSummary; + }; } export default function Dashboard() { const { props } = usePage(); - const { - netWorthEvolution, - accounts: accountMetrics, - topCategories, - isLoading, - refetch, - } = useDashboardData(); const { isKeySet, encryptedMessageData, fetchEncryptedMessage } = useEncryptionKey(); const [showUnlockDialog, setShowUnlockDialog] = useState(false); + const netWorthEvolution = props.netWorthEvolution ?? { + data: [], + accounts: {}, + currency_code: 'USD', + }; + + const accountMetrics = useMemo( + () => deriveAccountMetrics(netWorthEvolution), + [netWorthEvolution], + ); + + const topCategories = props.topCategories ?? []; + + const refetch = useCallback(() => { + router.reload({ + only: ['netWorthEvolution', 'topCategories', 'cashflowSummary'], + }); + }, []); + const breadcrumbs: BreadcrumbItem[] = [ { title: __('Dashboard'), @@ -82,38 +116,63 @@ export default function Dashboard() { description={__('Overview of your financial health')} /> - + + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ + } + > + -
- {isLoading - ? Array.from({ length: 4 }).map((_, i) => ( - - )) - : accountMetrics.map((account) => ( - - ))} -
+
+ {accountMetrics.map((account) => ( + + ))} +
+
- + + } + > + + {props.features.cashflow && ( - + } + > + + )}