diff --git a/resources/js/components/cashflow/breakdown-card.tsx b/resources/js/components/cashflow/breakdown-card.tsx index fa68f80c..6c7b5fe9 100644 --- a/resources/js/components/cashflow/breakdown-card.tsx +++ b/resources/js/components/cashflow/breakdown-card.tsx @@ -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({ {description} -
+
{data.data.map((item, index) => { const Icon = (Icons[ item.category.icon as keyof typeof Icons @@ -103,8 +108,21 @@ export function BreakdownCard({ index, ); - return ( -
+ 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 = ( + <>
+ + ); + + return categoryUrl ? ( + + {rowContent} + + ) : ( +
+ {rowContent}
); })} diff --git a/resources/js/components/charts/sankey-chart.tsx b/resources/js/components/charts/sankey-chart.tsx index a9edc67b..25ba2180 100644 --- a/resources/js/components/charts/sankey-chart.tsx +++ b/resources/js/components/charts/sankey-chart.tsx @@ -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(null); const [hoveredLink, setHoveredLink] = useState(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 = ( 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', )} > )} @@ -146,6 +147,7 @@ export default function CashflowPage() { data={incomeBreakdown} loading={isLoading} currency={auth.user.currency_code} + period={period} />
diff --git a/tests/Browser/CashflowCategoryNavigationTest.php b/tests/Browser/CashflowCategoryNavigationTest.php new file mode 100644 index 00000000..507cac42 --- /dev/null +++ b/tests/Browser/CashflowCategoryNavigationTest.php @@ -0,0 +1,65 @@ +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(); +});