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:
parent
cd40bc75d9
commit
ec245655b8
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue