feat(cashflow): make income/expense category rows clickable to transactions (#234)

## Summary

- Makes Income Sources and Expense Categories rows on the Cashflow page
clickable, navigating to the Transactions page pre-filtered by category
and date range
- Follows the exact same pattern as the dashboard's Top Spending
Categories card (`top-categories-card.tsx`)
- Fixes progress bar hover visibility by adding the `group` Tailwind
class to the link wrapper

## Changes

- **`resources/js/components/cashflow/breakdown-card.tsx`**: Added
optional `period` prop; each category row renders as an Inertia `<Link>`
with `category_ids`, `date_from`, and `date_to` query params when period
is provided; falls back to plain `div` otherwise
- **`resources/js/pages/cashflow/index.tsx`**: Passes `period` to both
`BreakdownCard` instances (income and expense)
- **`tests/Browser/CashflowCategoryNavigationTest.php`**: Browser tests
verifying clicking a category navigates to `/transactions` with the
correct query params
This commit is contained in:
Víctor Falcón 2026-03-17 10:57:40 +00:00 committed by GitHub
parent cd40bc75d9
commit ec245655b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 155 additions and 4 deletions

View File

@ -1,3 +1,4 @@
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 {
@ -13,6 +14,8 @@ import { useChartColors } from '@/hooks/use-chart-color-scheme';
import { cn } from '@/lib/utils';
import { getCategoryColorClasses } from '@/types/category';
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';
@ -21,6 +24,7 @@ interface BreakdownCardProps {
data: BreakdownData;
loading?: boolean;
currency?: string;
period?: { from: Date; to: Date };
}
export function BreakdownCard({
@ -28,6 +32,7 @@ export function BreakdownCard({
data,
loading,
currency = 'USD',
period,
}: BreakdownCardProps) {
const { categoryBarColor } = useChartColors();
const title =
@ -82,7 +87,7 @@ export function BreakdownCard({
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="space-y-4">
{data.data.map((item, index) => {
const Icon = (Icons[
item.category.icon as keyof typeof Icons
@ -103,8 +108,21 @@ export function BreakdownCard({
index,
);
return (
<div key={item.category_id} className="space-y-1.5">
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
@ -154,6 +172,20 @@ export function BreakdownCard({
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>
);
})}

View File

@ -1,3 +1,4 @@
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import {
Popover,
PopoverContent,
@ -16,6 +17,8 @@ import { cn } from '@/lib/utils';
import { Category } from '@/types/category';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { format } from 'date-fns';
import { useMemo, useState } from 'react';
interface SankeyChartProps {
@ -24,6 +27,7 @@ interface SankeyChartProps {
className?: string;
currency?: string;
groupingThreshold?: number;
period?: { from: Date; to: Date };
}
interface NodeData {
@ -142,6 +146,7 @@ export function SankeyChart({
className,
currency = 'USD',
groupingThreshold = 0.03,
period,
}: SankeyChartProps) {
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const [hoveredLink, setHoveredLink] = useState<string | null>(null);
@ -428,16 +433,62 @@ export function SankeyChart({
const otherGroup = isOtherNode
? otherGroups[node.id]
: null;
const categoryUrl =
node.category && period
? transactionsIndex({
query: {
category_ids: node.category.id,
date_from: format(
period.from,
'yyyy-MM-dd',
),
date_to: format(
period.to,
'yyyy-MM-dd',
),
},
}).url
: null;
const isNavigable =
!isOtherNode && categoryUrl !== null;
const nodeContent = (
<g
key={node.id}
onMouseEnter={() => setHoveredNode(node.id)}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => {
if (categoryUrl) {
router.visit(categoryUrl);
}
}}
onKeyDown={(event) => {
if (!categoryUrl) {
return;
}
if (
event.key === 'Enter' ||
event.key === ' '
) {
event.preventDefault();
router.visit(categoryUrl);
}
}}
role={isNavigable ? 'link' : undefined}
tabIndex={isNavigable ? 0 : undefined}
aria-label={
isNavigable
? `View ${node.label} transactions`
: undefined
}
className={cn(
'transition-all duration-200',
isOtherNode && 'cursor-pointer',
!isOtherNode && 'cursor-default',
isNavigable && 'cursor-pointer',
!isOtherNode &&
!isNavigable &&
'cursor-default',
)}
>
<rect

View File

@ -134,6 +134,7 @@ export default function CashflowPage() {
data={sankey}
height={400}
currency={auth.user.currency_code}
period={period}
/>
)}
</CardContent>
@ -146,6 +147,7 @@ export default function CashflowPage() {
data={incomeBreakdown}
loading={isLoading}
currency={auth.user.currency_code}
period={period}
/>
<BreakdownCard
@ -153,6 +155,7 @@ export default function CashflowPage() {
data={expenseBreakdown}
loading={isLoading}
currency={auth.user.currency_code}
period={period}
/>
</div>
</div>

View File

@ -0,0 +1,65 @@
<?php
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
it('clicking an expense category on cashflow page navigates to transactions with filters', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$category = Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Expense,
'name' => 'Groceries',
]);
Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => $category->id,
'amount' => -5000,
'transaction_date' => now()->startOfMonth(),
]);
$period = now()->format('Y-m');
$page = $this->actingAs($user)->visit("/cashflow?period={$period}");
$page->waitForText('Groceries', 10)
->click('Groceries')
->wait(2)
->assertPathIs('/transactions')
->assertQueryStringHas('category_ids', $category->id)
->assertNoJavascriptErrors();
});
it('clicking an income category on cashflow page navigates to transactions with filters', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$category = Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => $category->id,
'amount' => 200000,
'transaction_date' => now()->startOfMonth(),
]);
$period = now()->format('Y-m');
$page = $this->actingAs($user)->visit("/cashflow?period={$period}");
$page->waitForText('Salary', 10)
->click('Salary')
->wait(2)
->assertPathIs('/transactions')
->assertQueryStringHas('category_ids', $category->id)
->assertNoJavascriptErrors();
});