feat: expand parent categories inline in breakdowns (#486)
## What Expand/collapse child categories inline in three places: - Dashboard → **Top spending categories** - Cashflow → **Income sources** - Cashflow → **Expense categories** ## How it looks <img width="1225" height="928" alt="image" src="https://github.com/user-attachments/assets/aadce904-bfdd-46eb-b919-8695577845d7" /> ## Implementation **Frontend** - `useExpandableCategories` hook: tracks expanded set, lazy-fetches children once, caches, resets on period change. - `AnimatedCollapse` component for the height animation. - Recursive rows in `BreakdownCard` and `TopCategoriesCard`. **Backend** - Extracted `rollUpByTree`/`displayNodeFor` out of `CashflowAnalyticsController` into `CategoryTree::rollUp` (now shared by cashflow + dashboard). - Dashboard `top-categories` emits `has_children`/`is_direct` and accepts `?parent` to drill into a category's children (children + a direct "Parent" node), mirroring the existing cashflow breakdown drill. - Added Spanish translations for the new toggle labels. ## Tests - Backend: `has_children` true/false + parent-drill split (children + direct node) for dashboard top categories. - Component: chevron expands, lazily fetches `parent=…`, renders the child, flips to "Hide subcategories". - Full suite green; pint / eslint / prettier clean. > Note: children fetch lazily on first expand (same pattern as the Sankey inline expand).
This commit is contained in:
parent
679c8d7bff
commit
53d051800b
|
|
@ -7,6 +7,7 @@ use App\Enums\CategoryType;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\CategoryTree;
|
||||
use App\Services\ExchangeRateService;
|
||||
use App\Services\PeriodComparator;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -16,7 +17,10 @@ use Illuminate\Support\Collection;
|
|||
|
||||
class CashflowAnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(private ExchangeRateService $exchangeRateService) {}
|
||||
public function __construct(
|
||||
private ExchangeRateService $exchangeRateService,
|
||||
private CategoryTree $tree,
|
||||
) {}
|
||||
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
|
|
@ -290,7 +294,7 @@ class CashflowAnalyticsController extends Controller
|
|||
'amount' => $item['amount'],
|
||||
]);
|
||||
|
||||
$categorized = collect($this->rollUpByTree(
|
||||
$categorized = collect($this->tree->rollUp(
|
||||
$regularCategories->concat($transferCategories)->values()->all(),
|
||||
$userId,
|
||||
$drillParentId,
|
||||
|
|
@ -410,7 +414,7 @@ class CashflowAnalyticsController extends Controller
|
|||
'amount' => $item['amount'],
|
||||
]);
|
||||
|
||||
$categorized = collect($this->rollUpByTree($categorized->values()->all(), $userId, $drillParentId));
|
||||
$categorized = collect($this->tree->rollUp($categorized->values()->all(), $userId, $drillParentId));
|
||||
|
||||
$uncategorized = $transactions
|
||||
->filter(function (Transaction $transaction) use ($type): bool {
|
||||
|
|
@ -502,123 +506,4 @@ class CashflowAnalyticsController extends Controller
|
|||
{
|
||||
return $type === CategoryType::Income ? $amount > 0 : $amount < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll category amounts up the tree.
|
||||
*
|
||||
* With no drill target, every amount folds into its top-level ancestor.
|
||||
* When drilling into a parent, the parent's children become the nodes (each
|
||||
* rolled up over its own subtree) plus a "Parent" node for transactions
|
||||
* sitting on the parent itself. Items outside the drilled subtree drop out.
|
||||
*
|
||||
* @param array<int, array{category_id: ?string, category: Category|null, amount: int}> $categorized
|
||||
* @return array<int, array{category_id: ?string, category: Category|null, amount: int, has_children: bool, is_direct: bool}>
|
||||
*/
|
||||
private function rollUpByTree(array $categorized, string $userId, ?string $drillParentId): array
|
||||
{
|
||||
$categories = Category::query()
|
||||
->where('user_id', $userId)
|
||||
->forDisplay()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$parentMap = $categories->mapWithKeys(fn (Category $category): array => [$category->id => $category->parent_id])->all();
|
||||
$childCounts = [];
|
||||
foreach ($parentMap as $parentId) {
|
||||
if ($parentId !== null) {
|
||||
$childCounts[$parentId] = ($childCounts[$parentId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$nodes = [];
|
||||
foreach ($categorized as $item) {
|
||||
$categoryId = $item['category_id'];
|
||||
|
||||
if ($categoryId === null || ! array_key_exists($categoryId, $parentMap)) {
|
||||
// Uncategorized only belongs in the top-level view.
|
||||
if ($drillParentId === null) {
|
||||
$key = 'uncategorized';
|
||||
$nodes[$key] ??= ['category_id' => null, 'category' => $item['category'], 'amount' => 0, 'has_children' => false, 'is_direct' => false];
|
||||
$nodes[$key]['amount'] += $item['amount'];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $this->displayNodeFor($categoryId, $parentMap, $drillParentId);
|
||||
|
||||
if ($target === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$displayCategory = $categories->get($target['id']);
|
||||
|
||||
if ($displayCategory === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($target['is_direct']) {
|
||||
$key = $target['id'].':direct';
|
||||
$category = (new Category)->forceFill([
|
||||
'id' => $displayCategory->id,
|
||||
'name' => __('Parent'),
|
||||
'icon' => $displayCategory->icon,
|
||||
'color' => $displayCategory->color,
|
||||
'type' => $displayCategory->type,
|
||||
'cashflow_direction' => $displayCategory->cashflow_direction,
|
||||
'parent_id' => $displayCategory->parent_id,
|
||||
]);
|
||||
$nodes[$key] ??= ['category_id' => $displayCategory->id, 'category' => $category, 'amount' => 0, 'has_children' => false, 'is_direct' => true];
|
||||
$nodes[$key]['amount'] += $item['amount'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $target['id'];
|
||||
$nodes[$key] ??= [
|
||||
'category_id' => $displayCategory->id,
|
||||
'category' => $displayCategory,
|
||||
'amount' => 0,
|
||||
'has_children' => ($childCounts[$displayCategory->id] ?? 0) > 0,
|
||||
'is_direct' => false,
|
||||
];
|
||||
$nodes[$key]['amount'] += $item['amount'];
|
||||
}
|
||||
|
||||
return array_values($nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which node a category's amount should be attributed to.
|
||||
*
|
||||
* @param array<string, ?string> $parentMap
|
||||
* @return array{id: string, is_direct: bool}|null
|
||||
*/
|
||||
private function displayNodeFor(string $categoryId, array $parentMap, ?string $drillParentId): ?array
|
||||
{
|
||||
$chain = [];
|
||||
$current = $categoryId;
|
||||
$guard = 0;
|
||||
|
||||
while ($current !== null && $guard++ < Category::MAX_DEPTH + 1) {
|
||||
array_unshift($chain, $current);
|
||||
$current = $parentMap[$current] ?? null;
|
||||
}
|
||||
|
||||
if ($drillParentId === null) {
|
||||
return ['id' => $chain[0], 'is_direct' => false];
|
||||
}
|
||||
|
||||
$index = array_search($drillParentId, $chain, true);
|
||||
|
||||
if ($index === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($index === count($chain) - 1) {
|
||||
return ['id' => $drillParentId, 'is_direct' => true];
|
||||
}
|
||||
|
||||
return ['id' => $chain[$index + 1], 'is_direct' => false];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -432,13 +432,15 @@ class DashboardAnalyticsController extends Controller
|
|||
$validated = $request->validate([
|
||||
'from' => 'required|date',
|
||||
'to' => 'required|date',
|
||||
'parent' => 'nullable|uuid',
|
||||
]);
|
||||
|
||||
$period = PeriodComparator::fromRequest($validated);
|
||||
$previousPeriod = $period->previous();
|
||||
$drillParentId = $validated['parent'] ?? null;
|
||||
|
||||
$currentSpending = $this->getCategorySpending($request->user()->id, $period->from, $period->to);
|
||||
$previousSpending = $this->getCategorySpending($request->user()->id, $previousPeriod->from, $previousPeriod->to);
|
||||
$currentSpending = $this->getCategorySpending($request->user()->id, $period->from, $period->to, $drillParentId);
|
||||
$previousSpending = $this->getCategorySpending($request->user()->id, $previousPeriod->from, $previousPeriod->to, $drillParentId);
|
||||
|
||||
$totalAmount = $currentSpending->sum('amount');
|
||||
|
||||
|
|
@ -450,9 +452,12 @@ class DashboardAnalyticsController extends Controller
|
|||
|
||||
return [
|
||||
'category' => $item['category'],
|
||||
'category_id' => $item['category_id'],
|
||||
'amount' => $item['amount'],
|
||||
'previous_amount' => $previousAmount,
|
||||
'total_amount' => $totalAmount,
|
||||
'has_children' => $item['has_children'],
|
||||
'is_direct' => $item['is_direct'],
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
|
@ -461,16 +466,15 @@ class DashboardAnalyticsController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* Spending per top-level category: child category amounts roll up into
|
||||
* their root ancestor so the dashboard only lists parents.
|
||||
* Expense spending rolled up the category tree.
|
||||
*
|
||||
* @return Collection<int, array{category_id: string, amount: int, category: Category}>
|
||||
* Without a drill target, child amounts fold into their top-level ancestor
|
||||
* so only parents are listed. With one, the parent's children become the
|
||||
* rows (plus a direct node for transactions sitting on the parent itself).
|
||||
*/
|
||||
private function getCategorySpending(string $userId, Carbon $from, Carbon $to): Collection
|
||||
private function getCategorySpending(string $userId, Carbon $from, Carbon $to, ?string $drillParentId = null): Collection
|
||||
{
|
||||
$rootMap = $this->tree->rootAncestorMap($userId);
|
||||
|
||||
$rolledUp = Transaction::query()
|
||||
$perCategory = Transaction::query()
|
||||
->where('transactions.user_id', $userId)
|
||||
->whereBetween('transactions.transaction_date', [$from, $to])
|
||||
->join('categories', function ($join) {
|
||||
|
|
@ -481,21 +485,16 @@ class DashboardAnalyticsController extends Controller
|
|||
->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount'))
|
||||
->groupBy('transactions.category_id')
|
||||
->get()
|
||||
->groupBy(fn ($item): string => $rootMap[$item->category_id] ?? $item->category_id)
|
||||
->map(fn (Collection $items, string $rootId): array => [
|
||||
'category_id' => $rootId,
|
||||
'amount' => (int) -$items->sum('total_amount'),
|
||||
->map(fn ($item): array => [
|
||||
'category_id' => $item->category_id,
|
||||
'category' => null,
|
||||
'amount' => (int) -$item->total_amount,
|
||||
])
|
||||
->filter(fn (array $item): bool => $item['amount'] > 0);
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$categories = Category::query()
|
||||
->whereIn('id', $rolledUp->keys())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $rolledUp
|
||||
->map(fn (array $item): array => [...$item, 'category' => $categories->get($item['category_id'])])
|
||||
->filter(fn (array $item): bool => $item['category'] !== null)
|
||||
return collect($this->tree->rollUp($perCategory, $userId, $drillParentId))
|
||||
->filter(fn (array $item): bool => $item['amount'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ namespace App\Http\Controllers;
|
|||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\AccountMetricsService;
|
||||
use App\Services\CategoryTree;
|
||||
|
|
@ -71,9 +70,12 @@ class DashboardController extends Controller
|
|||
|
||||
return [
|
||||
'category' => $item['category'],
|
||||
'category_id' => $item['category_id'],
|
||||
'amount' => $item['amount'],
|
||||
'previous_amount' => $previousAmount,
|
||||
'total_amount' => $totalAmount,
|
||||
'has_children' => $item['has_children'],
|
||||
'is_direct' => $item['is_direct'],
|
||||
];
|
||||
})
|
||||
->values()
|
||||
|
|
@ -99,14 +101,10 @@ class DashboardController extends Controller
|
|||
/**
|
||||
* Spending per top-level category: child category amounts roll up into
|
||||
* their root ancestor so the dashboard only lists parents.
|
||||
*
|
||||
* @return Collection<int, array{category_id: string, amount: int, category: Category}>
|
||||
*/
|
||||
private function getCategorySpending(string $userId, Carbon $from, Carbon $to): Collection
|
||||
{
|
||||
$rootMap = $this->tree->rootAncestorMap($userId);
|
||||
|
||||
$rolledUp = Transaction::query()
|
||||
$perCategory = Transaction::query()
|
||||
->where('transactions.user_id', $userId)
|
||||
->whereBetween('transactions.transaction_date', [$from, $to])
|
||||
->join('categories', function ($join) {
|
||||
|
|
@ -117,21 +115,16 @@ class DashboardController extends Controller
|
|||
->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount'))
|
||||
->groupBy('transactions.category_id')
|
||||
->get()
|
||||
->groupBy(fn ($item): string => $rootMap[$item->category_id] ?? $item->category_id)
|
||||
->map(fn (Collection $items, string $rootId): array => [
|
||||
'category_id' => $rootId,
|
||||
'amount' => (int) -$items->sum('total_amount'),
|
||||
->map(fn ($item): array => [
|
||||
'category_id' => $item->category_id,
|
||||
'category' => null,
|
||||
'amount' => (int) -$item->total_amount,
|
||||
])
|
||||
->filter(fn (array $item): bool => $item['amount'] > 0);
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$categories = Category::query()
|
||||
->whereIn('id', $rolledUp->keys())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $rolledUp
|
||||
->map(fn (array $item): array => [...$item, 'category' => $categories->get($item['category_id'])])
|
||||
->filter(fn (array $item): bool => $item['category'] !== null)
|
||||
return collect($this->tree->rollUp($perCategory, $userId, null))
|
||||
->filter(fn (array $item): bool => $item['amount'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -135,33 +135,122 @@ class CategoryTree
|
|||
}
|
||||
|
||||
/**
|
||||
* Map every category id of a user to its top-level ancestor id (roots map
|
||||
* to themselves). Used to roll child amounts up into their parent.
|
||||
* Roll category amounts up the tree.
|
||||
*
|
||||
* @return array<string, string>
|
||||
* With no drill target, every amount folds into its top-level ancestor.
|
||||
* When drilling into a parent, the parent's children become the nodes (each
|
||||
* rolled up over its own subtree) plus a "Parent" node for transactions
|
||||
* sitting on the parent itself. Items outside the drilled subtree drop out.
|
||||
*
|
||||
* @param array<int, array{category_id: ?string, category: Category|null, amount: int}> $categorized
|
||||
* @return array<int, array{category_id: ?string, category: Category|null, amount: int, has_children: bool, is_direct: bool}>
|
||||
*/
|
||||
public function rootAncestorMap(string $userId): array
|
||||
public function rollUp(array $categorized, string $userId, ?string $drillParentId): array
|
||||
{
|
||||
$parents = Category::query()
|
||||
$categories = Category::query()
|
||||
->where('user_id', $userId)
|
||||
->pluck('parent_id', 'id')
|
||||
->all();
|
||||
->forDisplay()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$map = [];
|
||||
|
||||
foreach ($parents as $id => $parentId) {
|
||||
$rootId = $id;
|
||||
$guard = 0;
|
||||
|
||||
while ($parentId !== null && array_key_exists($parentId, $parents) && $guard++ < Category::MAX_DEPTH) {
|
||||
$rootId = $parentId;
|
||||
$parentId = $parents[$parentId];
|
||||
$parentMap = $categories->mapWithKeys(fn (Category $category): array => [$category->id => $category->parent_id])->all();
|
||||
$childCounts = [];
|
||||
foreach ($parentMap as $parentId) {
|
||||
if ($parentId !== null) {
|
||||
$childCounts[$parentId] = ($childCounts[$parentId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$map[$id] = $rootId;
|
||||
}
|
||||
|
||||
return $map;
|
||||
$nodes = [];
|
||||
foreach ($categorized as $item) {
|
||||
$categoryId = $item['category_id'];
|
||||
|
||||
if ($categoryId === null || ! array_key_exists($categoryId, $parentMap)) {
|
||||
// Uncategorized only belongs in the top-level view.
|
||||
if ($drillParentId === null) {
|
||||
$key = 'uncategorized';
|
||||
$nodes[$key] ??= ['category_id' => null, 'category' => $item['category'], 'amount' => 0, 'has_children' => false, 'is_direct' => false];
|
||||
$nodes[$key]['amount'] += $item['amount'];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $this->displayNodeFor($categoryId, $parentMap, $drillParentId);
|
||||
|
||||
if ($target === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$displayCategory = $categories->get($target['id']);
|
||||
|
||||
if ($displayCategory === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($target['is_direct']) {
|
||||
$key = $target['id'].':direct';
|
||||
$category = (new Category)->forceFill([
|
||||
'id' => $displayCategory->id,
|
||||
'name' => __('Parent'),
|
||||
'icon' => $displayCategory->icon,
|
||||
'color' => $displayCategory->color,
|
||||
'type' => $displayCategory->type,
|
||||
'cashflow_direction' => $displayCategory->cashflow_direction,
|
||||
'parent_id' => $displayCategory->parent_id,
|
||||
]);
|
||||
$nodes[$key] ??= ['category_id' => $displayCategory->id, 'category' => $category, 'amount' => 0, 'has_children' => false, 'is_direct' => true];
|
||||
$nodes[$key]['amount'] += $item['amount'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $target['id'];
|
||||
$nodes[$key] ??= [
|
||||
'category_id' => $displayCategory->id,
|
||||
'category' => $displayCategory,
|
||||
'amount' => 0,
|
||||
'has_children' => ($childCounts[$displayCategory->id] ?? 0) > 0,
|
||||
'is_direct' => false,
|
||||
];
|
||||
$nodes[$key]['amount'] += $item['amount'];
|
||||
}
|
||||
|
||||
return array_values($nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which node a category's amount should be attributed to.
|
||||
*
|
||||
* @param array<string, ?string> $parentMap
|
||||
* @return array{id: string, is_direct: bool}|null
|
||||
*/
|
||||
private function displayNodeFor(string $categoryId, array $parentMap, ?string $drillParentId): ?array
|
||||
{
|
||||
$chain = [];
|
||||
$current = $categoryId;
|
||||
$guard = 0;
|
||||
|
||||
while ($current !== null && $guard++ < Category::MAX_DEPTH + 1) {
|
||||
array_unshift($chain, $current);
|
||||
$current = $parentMap[$current] ?? null;
|
||||
}
|
||||
|
||||
if ($drillParentId === null) {
|
||||
return ['id' => $chain[0], 'is_direct' => false];
|
||||
}
|
||||
|
||||
$index = array_search($drillParentId, $chain, true);
|
||||
|
||||
if ($index === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($index === count($chain) - 1) {
|
||||
return ['id' => $drillParentId, 'is_direct' => true];
|
||||
}
|
||||
|
||||
return ['id' => $chain[$index + 1], 'is_direct' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -698,6 +698,7 @@
|
|||
"Hey there!": "¡Hola!",
|
||||
"Hey! A quick heads-up.": "¡Hola! Un aviso rápido.",
|
||||
"Hey! We have some great news.": "¡Hola! Tenemos buenas noticias.",
|
||||
"Hide subcategories": "Ocultar subcategorías",
|
||||
"Hi :name,": "Hola :name,",
|
||||
"Hi! I'm Victor, the founder of Whisper Money. I wanted to personally welcome you and thank you for joining us.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Quería darte la bienvenida personalmente y agradecerte por unirte.",
|
||||
"Hi! It's Victor and Álvaro here, the founders of Whisper Money. You've been using the app for a few days now, and we'd love to hear how it's working for you.": "¡Hola! Somos Víctor y Álvaro, los fundadores de Whisper Money. Llevas unos días usando la app y nos encantaría saber cómo te está funcionando.",
|
||||
|
|
@ -1300,6 +1301,7 @@
|
|||
"Shared with": "Compartido con",
|
||||
"Shopping (Clothing, Electronics, Gifts)": "Compras (Ropa, Electrónica, Regalos)",
|
||||
"Show as cash inflow": "Mostrar como entrada de dinero",
|
||||
"Show subcategories": "Mostrar subcategorías",
|
||||
"Show as cash outflow": "Mostrar como salida de dinero",
|
||||
"Show in account currency (:currency)": "Mostrar en moneda de la cuenta (:currency)",
|
||||
"Show in cashflow chart as inflow": "Mostrar en el gráfico de flujo de caja como entrada",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BreakdownCard } from './breakdown-card';
|
||||
|
||||
vi.mock('@/components/ui/amount-display', () => ({
|
||||
|
|
@ -45,4 +45,87 @@ describe('BreakdownCard', () => {
|
|||
expect(screen.getByText('Uncategorized')).toBeInTheDocument();
|
||||
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands a parent category and loads its children on demand', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
category: {
|
||||
id: 'child-1',
|
||||
name: 'Groceries',
|
||||
icon: 'ShoppingCart',
|
||||
color: 'blue',
|
||||
type: 'expense',
|
||||
cashflow_direction: 'outflow',
|
||||
parent_id: 'parent-1',
|
||||
},
|
||||
category_id: 'child-1',
|
||||
amount: 4000,
|
||||
percentage: 100,
|
||||
previous_amount: 0,
|
||||
has_children: false,
|
||||
is_direct: false,
|
||||
},
|
||||
],
|
||||
total: 4000,
|
||||
previous_total: 0,
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(
|
||||
<BreakdownCard
|
||||
type="expense"
|
||||
data={{
|
||||
data: [
|
||||
{
|
||||
category: {
|
||||
id: 'parent-1',
|
||||
name: 'Food',
|
||||
icon: 'Utensils',
|
||||
color: 'amber',
|
||||
type: 'expense',
|
||||
cashflow_direction: 'outflow',
|
||||
parent_id: null,
|
||||
},
|
||||
category_id: 'parent-1',
|
||||
amount: 4000,
|
||||
percentage: 100,
|
||||
previous_amount: 0,
|
||||
has_children: true,
|
||||
is_direct: false,
|
||||
},
|
||||
],
|
||||
total: 4000,
|
||||
previous_total: 0,
|
||||
}}
|
||||
currency="USD"
|
||||
period={{
|
||||
from: new Date('2026-06-01'),
|
||||
to: new Date('2026-06-30'),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Groceries')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Show subcategories' }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Groceries')).toBeInTheDocument(),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('parent=parent-1'),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Hide subcategories' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator';
|
||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { AnimatedCollapse } from '@/components/ui/animated-collapse';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -9,8 +10,12 @@ import {
|
|||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { BreakdownData } from '@/hooks/use-cashflow-data';
|
||||
import { BreakdownData, BreakdownItem } from '@/hooks/use-cashflow-data';
|
||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||
import {
|
||||
type ExpandableCategories,
|
||||
useExpandableCategories,
|
||||
} from '@/hooks/use-expandable-categories';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
type CategoryColor,
|
||||
|
|
@ -21,7 +26,14 @@ import { __ } from '@/utils/i18n';
|
|||
import { Link } from '@inertiajs/react';
|
||||
import { format } from 'date-fns';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
ChevronsDown,
|
||||
ChevronsUp,
|
||||
Loader2,
|
||||
LucideIcon,
|
||||
Minus,
|
||||
} from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface BreakdownCardProps {
|
||||
type: 'income' | 'expense';
|
||||
|
|
@ -37,6 +49,171 @@ const fallbackCategory = {
|
|||
color: 'gray' as CategoryColor,
|
||||
};
|
||||
|
||||
function rowKey(item: BreakdownItem): string {
|
||||
return `${item.category_id ?? 'uncategorized'}:${item.is_direct ? 'direct' : 'node'}`;
|
||||
}
|
||||
|
||||
interface BreakdownRowProps {
|
||||
item: BreakdownItem;
|
||||
index: number;
|
||||
type: 'income' | 'expense';
|
||||
currency: string;
|
||||
period?: { from: Date; to: Date };
|
||||
expandable: ExpandableCategories<BreakdownItem>;
|
||||
}
|
||||
|
||||
function BreakdownRow({
|
||||
item,
|
||||
index,
|
||||
type,
|
||||
currency,
|
||||
period,
|
||||
expandable,
|
||||
}: BreakdownRowProps) {
|
||||
const { categoryBarColor } = useChartColors();
|
||||
const category = item.category ?? fallbackCategory;
|
||||
const Icon = (Icons[category.icon as keyof typeof Icons] ||
|
||||
Icons.HelpCircle) as LucideIcon;
|
||||
|
||||
const percentageChange =
|
||||
item.previous_amount > 0
|
||||
? ((item.amount - item.previous_amount) / item.previous_amount) *
|
||||
100
|
||||
: null;
|
||||
|
||||
const categoryColor = getCategoryColorClasses(category.color);
|
||||
const chartColor = categoryBarColor(category.color, index);
|
||||
|
||||
const canExpand = Boolean(
|
||||
item.has_children && !item.is_direct && item.category_id && period,
|
||||
);
|
||||
const id = item.category_id ?? '';
|
||||
const expanded = canExpand && expandable.isExpanded(id);
|
||||
const loading = canExpand && expandable.isLoading(id);
|
||||
|
||||
const categoryUrl =
|
||||
period && item.category_id
|
||||
? transactionsIndex({
|
||||
query: {
|
||||
category_ids: item.category_id,
|
||||
date_from: format(period.from, 'yyyy-MM-dd'),
|
||||
date_to: format(period.to, 'yyyy-MM-dd'),
|
||||
},
|
||||
}).url
|
||||
: null;
|
||||
|
||||
const header = (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 overflow-hidden">
|
||||
<div className="flex max-w-[60%] grow items-center gap-2">
|
||||
<div
|
||||
className={cn([
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
`${categoryColor.bg} ${categoryColor.text}`,
|
||||
])}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{category.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{percentageChange !== null && (
|
||||
<PercentageTrendIndicator
|
||||
trend={percentageChange}
|
||||
label=""
|
||||
previousAmount={item.previous_amount}
|
||||
currentAmount={item.amount}
|
||||
currencyCode={currency}
|
||||
invertColors={type === 'expense'}
|
||||
className="shrink-0 text-xs"
|
||||
/>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
{item.percentage.toFixed(0)}%
|
||||
</span>
|
||||
<AmountDisplay
|
||||
amountInCents={item.amount}
|
||||
currencyCode={currency}
|
||||
variant="compact"
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-0">
|
||||
{canExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => expandable.toggle(id)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={
|
||||
expanded
|
||||
? __('Hide subcategories')
|
||||
: __('Show subcategories')
|
||||
}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : expanded ? (
|
||||
<ChevronsUp className="size-4" />
|
||||
) : (
|
||||
<ChevronsDown className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-6 shrink-0 items-center justify-center text-muted-foreground/30"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
{categoryUrl ? (
|
||||
<Link
|
||||
href={categoryUrl}
|
||||
className="group block grow rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
|
||||
>
|
||||
{header}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="grow px-1.5 py-1">{header}</div>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={item.percentage}
|
||||
className="h-1.5 w-full"
|
||||
indicatorColor={chartColor}
|
||||
/>
|
||||
|
||||
{canExpand && (
|
||||
<AnimatedCollapse open={expanded}>
|
||||
<div className="ml-[11px] space-y-1.5 border-l border-border pt-1.5 pl-3">
|
||||
{expandable.getChildren(id).map((child, childIndex) => (
|
||||
<BreakdownRow
|
||||
key={rowKey(child)}
|
||||
item={child}
|
||||
index={childIndex}
|
||||
type={type}
|
||||
currency={currency}
|
||||
period={period}
|
||||
expandable={expandable}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AnimatedCollapse>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreakdownCard({
|
||||
type,
|
||||
data,
|
||||
|
|
@ -44,7 +221,6 @@ export function BreakdownCard({
|
|||
currency = 'USD',
|
||||
period,
|
||||
}: BreakdownCardProps) {
|
||||
const { categoryBarColor } = useChartColors();
|
||||
const title =
|
||||
type === 'income' ? __('Income Sources') : __('Expense Categories');
|
||||
const description =
|
||||
|
|
@ -56,6 +232,36 @@ export function BreakdownCard({
|
|||
? __('No income this period')
|
||||
: __('No expenses this period');
|
||||
|
||||
const periodKey = period
|
||||
? `${format(period.from, 'yyyy-MM-dd')}:${format(period.to, 'yyyy-MM-dd')}`
|
||||
: null;
|
||||
|
||||
const fetchChildren = useCallback(
|
||||
async (categoryId: string): Promise<BreakdownItem[]> => {
|
||||
if (!period) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
from: format(period.from, 'yyyy-MM-dd'),
|
||||
to: format(period.to, 'yyyy-MM-dd'),
|
||||
type,
|
||||
parent: categoryId,
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/cashflow/breakdown?${params.toString()}`,
|
||||
);
|
||||
const json: BreakdownData = await response.json();
|
||||
return json.data;
|
||||
},
|
||||
[period, type],
|
||||
);
|
||||
|
||||
const expandable = useExpandableCategories<BreakdownItem>(
|
||||
fetchChildren,
|
||||
periodKey,
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -97,109 +303,18 @@ export function BreakdownCard({
|
|||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.data.map((item, index) => {
|
||||
const category = item.category ?? fallbackCategory;
|
||||
const Icon = (Icons[
|
||||
category.icon as keyof typeof Icons
|
||||
] || Icons.HelpCircle) as LucideIcon;
|
||||
|
||||
const percentageChange =
|
||||
item.previous_amount > 0
|
||||
? ((item.amount - item.previous_amount) /
|
||||
item.previous_amount) *
|
||||
100
|
||||
: null;
|
||||
|
||||
const categoryColor = getCategoryColorClasses(
|
||||
category.color,
|
||||
);
|
||||
const chartColor = categoryBarColor(
|
||||
category.color,
|
||||
index,
|
||||
);
|
||||
|
||||
const categoryUrl = period
|
||||
? transactionsIndex({
|
||||
query: {
|
||||
category_ids: item.category_id,
|
||||
date_from: format(
|
||||
period.from,
|
||||
'yyyy-MM-dd',
|
||||
),
|
||||
date_to: format(period.to, 'yyyy-MM-dd'),
|
||||
},
|
||||
}).url
|
||||
: null;
|
||||
|
||||
const rowContent = (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 overflow-hidden">
|
||||
<div className="flex max-w-[60%] grow gap-2">
|
||||
<div
|
||||
className={cn([
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
`${categoryColor.bg} ${categoryColor.text}`,
|
||||
])}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{category.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{percentageChange !== null && (
|
||||
<PercentageTrendIndicator
|
||||
trend={percentageChange}
|
||||
label=""
|
||||
previousAmount={
|
||||
item.previous_amount
|
||||
}
|
||||
currentAmount={item.amount}
|
||||
currencyCode={currency}
|
||||
invertColors={
|
||||
type === 'expense'
|
||||
}
|
||||
className="shrink-0 text-xs"
|
||||
/>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
{item.percentage.toFixed(0)}%
|
||||
</span>
|
||||
<AmountDisplay
|
||||
amountInCents={item.amount}
|
||||
currencyCode={currency}
|
||||
variant="compact"
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
value={item.percentage}
|
||||
className="h-1.5"
|
||||
indicatorColor={chartColor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return categoryUrl ? (
|
||||
<Link
|
||||
key={item.category_id}
|
||||
href={categoryUrl}
|
||||
className="group -mx-1.5 my-1.5 block space-y-1.5 rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
|
||||
>
|
||||
{rowContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={item.category_id} className="space-y-1.5">
|
||||
{rowContent}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-2.5">
|
||||
{data.data.map((item, index) => (
|
||||
<BreakdownRow
|
||||
key={rowKey(item)}
|
||||
item={item}
|
||||
index={index}
|
||||
type={type}
|
||||
currency={currency}
|
||||
period={period}
|
||||
expandable={expandable}
|
||||
/>
|
||||
))}
|
||||
{data.data.length === 0 && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { AnimatedCollapse } from '@/components/ui/animated-collapse';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -9,6 +10,10 @@ import {
|
|||
} from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||
import {
|
||||
type ExpandableCategories,
|
||||
useExpandableCategories,
|
||||
} from '@/hooks/use-expandable-categories';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedData } from '@/types';
|
||||
import {
|
||||
|
|
@ -20,7 +25,14 @@ import { __ } from '@/utils/i18n';
|
|||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
ChevronsDown,
|
||||
ChevronsUp,
|
||||
Loader2,
|
||||
LucideIcon,
|
||||
Minus,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { PercentageTrendIndicator } from './percentage-trend-indicator';
|
||||
|
||||
interface CategoryData {
|
||||
|
|
@ -29,6 +41,8 @@ interface CategoryData {
|
|||
amount: number;
|
||||
previous_amount: number;
|
||||
total_amount: number;
|
||||
has_children?: boolean;
|
||||
is_direct?: boolean;
|
||||
}
|
||||
|
||||
interface TopCategoriesCardProps {
|
||||
|
|
@ -36,12 +50,193 @@ interface TopCategoriesCardProps {
|
|||
loading?: boolean;
|
||||
}
|
||||
|
||||
function rowKey(item: CategoryData): string {
|
||||
return `${item.category?.id ?? item.category_id ?? 'uncategorized'}:${item.is_direct ? 'direct' : 'node'}`;
|
||||
}
|
||||
|
||||
interface CategoryRowProps {
|
||||
item: CategoryData;
|
||||
index: number;
|
||||
currencyCode: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
expandable: ExpandableCategories<CategoryData>;
|
||||
}
|
||||
|
||||
function CategoryRow({
|
||||
item,
|
||||
index,
|
||||
currencyCode,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
expandable,
|
||||
}: CategoryRowProps) {
|
||||
const { categoryBarColor } = useChartColors();
|
||||
const category = item.category;
|
||||
const categoryId = category?.id ?? item.category_id ?? 'uncategorized';
|
||||
const categoryName = category?.name ?? __('Uncategorized');
|
||||
const categoryIcon = category?.icon ?? 'HelpCircle';
|
||||
const categoryColorName = category?.color ?? ('gray' as CategoryColor);
|
||||
const Icon = (Icons[categoryIcon as keyof typeof Icons] ||
|
||||
Icons.HelpCircle) as LucideIcon;
|
||||
|
||||
const percentageChange =
|
||||
item.previous_amount > 0
|
||||
? ((item.amount - item.previous_amount) / item.previous_amount) *
|
||||
100
|
||||
: null;
|
||||
const percentage =
|
||||
item.total_amount > 0 ? (item.amount / item.total_amount) * 100 : 0;
|
||||
const categoryColor = getCategoryColorClasses(categoryColorName);
|
||||
const chartColor = categoryBarColor(categoryColorName, index);
|
||||
|
||||
const canExpand = Boolean(item.has_children && !item.is_direct && category);
|
||||
const expanded = canExpand && expandable.isExpanded(categoryId);
|
||||
const loading = canExpand && expandable.isLoading(categoryId);
|
||||
|
||||
const categoryUrl = transactionsIndex({
|
||||
query: {
|
||||
category_ids: categoryId,
|
||||
date_from: dateFrom,
|
||||
date_to: dateTo,
|
||||
},
|
||||
}).url;
|
||||
|
||||
const header = (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn([
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
`${categoryColor.bg} ${categoryColor.text}`,
|
||||
])}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{categoryName}
|
||||
</span>
|
||||
{percentageChange !== null && (
|
||||
<PercentageTrendIndicator
|
||||
trend={percentageChange}
|
||||
label=""
|
||||
previousAmount={item.previous_amount}
|
||||
currentAmount={item.amount}
|
||||
currencyCode={currencyCode}
|
||||
invertColors
|
||||
className="shrink-0 text-xs"
|
||||
/>
|
||||
)}
|
||||
<AmountDisplay
|
||||
amountInCents={item.amount}
|
||||
currencyCode={currencyCode}
|
||||
variant="compact"
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-0">
|
||||
{canExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => expandable.toggle(categoryId)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={
|
||||
expanded
|
||||
? __('Hide subcategories')
|
||||
: __('Show subcategories')
|
||||
}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : expanded ? (
|
||||
<ChevronsUp className="size-4" />
|
||||
) : (
|
||||
<ChevronsDown className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-6 shrink-0 items-center justify-center text-muted-foreground/30"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
href={categoryUrl}
|
||||
className="group block grow rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
|
||||
>
|
||||
{header}
|
||||
</Link>
|
||||
</div>
|
||||
<Progress
|
||||
value={percentage}
|
||||
className="h-2 w-full"
|
||||
indicatorColor={chartColor}
|
||||
/>
|
||||
|
||||
{canExpand && (
|
||||
<AnimatedCollapse open={expanded}>
|
||||
<div className="ml-[11px] space-y-2 border-l border-border pt-2 pl-3">
|
||||
{expandable
|
||||
.getChildren(categoryId)
|
||||
.map((child, childIndex) => (
|
||||
<CategoryRow
|
||||
key={rowKey(child)}
|
||||
item={child}
|
||||
index={childIndex}
|
||||
currencyCode={currencyCode}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
expandable={expandable}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AnimatedCollapse>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopCategoriesCard({
|
||||
categories,
|
||||
loading,
|
||||
}: TopCategoriesCardProps) {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
const { categoryBarColor } = useChartColors();
|
||||
|
||||
const { dateFrom, dateTo } = useMemo(() => {
|
||||
const now = new Date();
|
||||
return {
|
||||
dateFrom: format(subDays(now, 30), 'yyyy-MM-dd'),
|
||||
dateTo: format(now, 'yyyy-MM-dd'),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchChildren = useCallback(
|
||||
async (categoryId: string): Promise<CategoryData[]> => {
|
||||
const params = new URLSearchParams({
|
||||
from: dateFrom,
|
||||
to: dateTo,
|
||||
parent: categoryId,
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/dashboard/top-categories?${params.toString()}`,
|
||||
);
|
||||
return response.json();
|
||||
},
|
||||
[dateFrom, dateTo],
|
||||
);
|
||||
|
||||
const expandable = useExpandableCategories<CategoryData>(
|
||||
fetchChildren,
|
||||
dateFrom,
|
||||
);
|
||||
|
||||
if (loading || !auth?.user) {
|
||||
return (
|
||||
|
|
@ -67,10 +262,6 @@ export function TopCategoriesCard({
|
|||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateFrom = format(subDays(now, 30), 'yyyy-MM-dd');
|
||||
const dateTo = format(now, 'yyyy-MM-dd');
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="gap-2">
|
||||
|
|
@ -78,95 +269,18 @@ export function TopCategoriesCard({
|
|||
<CardDescription>{__('on the last 30 days')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{categories.map((item, index) => {
|
||||
const category = item.category;
|
||||
const categoryId =
|
||||
category?.id ?? item.category_id ?? 'uncategorized';
|
||||
const categoryName =
|
||||
category?.name ?? __('Uncategorized');
|
||||
const categoryIcon = category?.icon ?? 'HelpCircle';
|
||||
const categoryColorName =
|
||||
category?.color ?? ('gray' as CategoryColor);
|
||||
const Icon = (Icons[
|
||||
categoryIcon as keyof typeof Icons
|
||||
] || Icons.HelpCircle) as LucideIcon;
|
||||
|
||||
const percentageChange =
|
||||
item.previous_amount > 0
|
||||
? ((item.amount - item.previous_amount) /
|
||||
item.previous_amount) *
|
||||
100
|
||||
: null;
|
||||
const percentage =
|
||||
item.total_amount > 0
|
||||
? (item.amount / item.total_amount) * 100
|
||||
: 0;
|
||||
const categoryColor =
|
||||
getCategoryColorClasses(categoryColorName);
|
||||
const chartColor = categoryBarColor(
|
||||
categoryColorName,
|
||||
index,
|
||||
);
|
||||
|
||||
const categoryUrl = transactionsIndex({
|
||||
query: {
|
||||
category_ids: categoryId,
|
||||
date_from: dateFrom,
|
||||
date_to: dateTo,
|
||||
},
|
||||
}).url;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={categoryId}
|
||||
href={categoryUrl}
|
||||
className="group -mx-1.5 my-1.5 block space-y-2 rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn([
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
`${categoryColor.bg} ${categoryColor.text}`,
|
||||
])}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{categoryName}
|
||||
</span>
|
||||
{percentageChange !== null && (
|
||||
<PercentageTrendIndicator
|
||||
trend={percentageChange}
|
||||
label=""
|
||||
previousAmount={
|
||||
item.previous_amount
|
||||
}
|
||||
currentAmount={item.amount}
|
||||
currencyCode={
|
||||
auth.user.currency_code
|
||||
}
|
||||
invertColors
|
||||
className="shrink-0 text-xs"
|
||||
/>
|
||||
)}
|
||||
<AmountDisplay
|
||||
amountInCents={item.amount}
|
||||
currencyCode={auth.user.currency_code}
|
||||
variant="compact"
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<Progress
|
||||
value={percentage}
|
||||
className="h-2"
|
||||
indicatorColor={chartColor}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-3">
|
||||
{categories.map((item, index) => (
|
||||
<CategoryRow
|
||||
key={rowKey(item)}
|
||||
item={item}
|
||||
index={index}
|
||||
currencyCode={auth.user.currency_code}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
expandable={expandable}
|
||||
/>
|
||||
))}
|
||||
{categories.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
{__('No spending data this month')}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface AnimatedCollapseProps {
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates its content open/closed by transitioning the grid row track from
|
||||
* `0fr` to `1fr`, which lets an auto-height element grow and shrink smoothly
|
||||
* without measuring it in JavaScript.
|
||||
*/
|
||||
export function AnimatedCollapse({
|
||||
open,
|
||||
children,
|
||||
className,
|
||||
}: AnimatedCollapseProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
|
||||
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
)}
|
||||
>
|
||||
<div className={cn('min-h-0 overflow-hidden', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,10 +46,13 @@ export interface DashboardData {
|
|||
netWorthEvolution: NetWorthEvolutionData;
|
||||
accounts: AccountWithMetrics[];
|
||||
topCategories: Array<{
|
||||
category: Category;
|
||||
category: Category | null;
|
||||
category_id?: string | null;
|
||||
amount: number;
|
||||
previous_amount: number;
|
||||
total_amount: number;
|
||||
has_children?: boolean;
|
||||
is_direct?: boolean;
|
||||
}>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface ExpandableCategories<T> {
|
||||
isExpanded: (id: string) => boolean;
|
||||
isLoading: (id: string) => boolean;
|
||||
getChildren: (id: string) => T[];
|
||||
toggle: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which parent categories are expanded and lazily fetches their
|
||||
* children once, caching the result. Expansions and the cache are cleared
|
||||
* whenever `resetKey` changes (e.g. the selected period).
|
||||
*/
|
||||
export function useExpandableCategories<T>(
|
||||
fetchChildren: (categoryId: string) => Promise<T[]>,
|
||||
resetKey?: unknown,
|
||||
): ExpandableCategories<T> {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [childrenById, setChildrenById] = useState<Record<string, T[]>>({});
|
||||
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchRef = useRef(fetchChildren);
|
||||
fetchRef.current = fetchChildren;
|
||||
const loadedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(new Set());
|
||||
setChildrenById({});
|
||||
setLoadingIds(new Set());
|
||||
loadedRef.current = new Set();
|
||||
}, [resetKey]);
|
||||
|
||||
const toggle = useCallback((id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (loadedRef.current.has(id)) {
|
||||
return;
|
||||
}
|
||||
loadedRef.current.add(id);
|
||||
setLoadingIds((prev) => new Set(prev).add(id));
|
||||
|
||||
fetchRef
|
||||
.current(id)
|
||||
.then((data) =>
|
||||
setChildrenById((current) => ({ ...current, [id]: data })),
|
||||
)
|
||||
.catch((error) => {
|
||||
loadedRef.current.delete(id);
|
||||
console.error('Failed to load subcategories:', error);
|
||||
})
|
||||
.finally(() =>
|
||||
setLoadingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(id: string) => expanded.has(id),
|
||||
[expanded],
|
||||
);
|
||||
const isLoading = useCallback(
|
||||
(id: string) => loadingIds.has(id),
|
||||
[loadingIds],
|
||||
);
|
||||
const getChildren = useCallback(
|
||||
(id: string) => childrenById[id] ?? [],
|
||||
[childrenById],
|
||||
);
|
||||
|
||||
return { isExpanded, isLoading, getChildren, toggle };
|
||||
}
|
||||
|
|
@ -29,10 +29,13 @@ interface DashboardProps extends SharedData {
|
|||
showEncryptionPrompt: boolean;
|
||||
netWorthEvolution?: NetWorthEvolutionData;
|
||||
topCategories?: Array<{
|
||||
category: Category;
|
||||
category: Category | null;
|
||||
category_id?: string | null;
|
||||
amount: number;
|
||||
previous_amount: number;
|
||||
total_amount: number;
|
||||
has_children?: boolean;
|
||||
is_direct?: boolean;
|
||||
}>;
|
||||
cashflowSummary?: {
|
||||
current: CashflowSummary;
|
||||
|
|
|
|||
|
|
@ -369,6 +369,63 @@ test('top categories rolls child spending up into the top-level parent', functio
|
|||
expect($data[0]['category']['id'])->toBe($food->id);
|
||||
expect($data[0]['amount'])->toBe(6000);
|
||||
expect($data[0]['total_amount'])->toBe(6000);
|
||||
expect($data[0]['has_children'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('top categories flags parents without children as not expandable', function () {
|
||||
$rent = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Rent']);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'category_id' => $rent->id,
|
||||
'amount' => -1000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/top-categories?'.http_build_query([
|
||||
'from' => now()->startOfMonth()->toDateString(),
|
||||
'to' => now()->endOfMonth()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('0.has_children'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('drilling into a top category splits it into children plus a direct node', function () {
|
||||
$food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
|
||||
$groceries = Category::factory()->childOf($food)->create(['user_id' => $this->user->id, 'name' => 'Groceries']);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'category_id' => $food->id,
|
||||
'amount' => -1000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'category_id' => $groceries->id,
|
||||
'amount' => -2000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/top-categories?'.http_build_query([
|
||||
'from' => now()->startOfMonth()->toDateString(),
|
||||
'to' => now()->endOfMonth()->toDateString(),
|
||||
'parent' => $food->id,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = collect($response->json());
|
||||
|
||||
expect($data)->toHaveCount(2);
|
||||
|
||||
$childNode = $data->firstWhere('is_direct', false);
|
||||
expect($childNode['category_id'])->toBe($groceries->id)
|
||||
->and($childNode['amount'])->toBe(2000);
|
||||
|
||||
$directNode = $data->firstWhere('is_direct', true);
|
||||
expect($directNode['category_id'])->toBe($food->id)
|
||||
->and($directNode['amount'])->toBe(1000);
|
||||
});
|
||||
|
||||
test('net worth evolution returns monthly data points with per-account balances', function () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue