diff --git a/app/Http/Controllers/Api/TransactionAnalysisController.php b/app/Http/Controllers/Api/TransactionAnalysisController.php index ffc0d86a..f3558da0 100644 --- a/app/Http/Controllers/Api/TransactionAnalysisController.php +++ b/app/Http/Controllers/Api/TransactionAnalysisController.php @@ -141,11 +141,13 @@ class TransactionAnalysisController extends Controller 'category_id' => $node['category_id'], 'name' => $node['category']->name, 'color' => $node['category']->color, + 'icon' => $node['category']->icon, 'amount' => $node['amount'], 'children' => array_map(fn (array $child): array => [ 'category_id' => $child['category_id'], 'name' => $child['category']->name, 'color' => $child['category']->color, + 'icon' => $child['category']->icon, 'amount' => $child['amount'], ], $node['children']), ], $this->tree->spendingBreakdown($grouped, $userId)); @@ -159,6 +161,7 @@ class TransactionAnalysisController extends Controller 'category_id' => null, 'name' => __('Uncategorized'), 'color' => 'gray', + 'icon' => 'HelpCircle', 'amount' => $uncategorized, 'children' => [], ]; diff --git a/resources/js/components/cashflow/breakdown-card.tsx b/resources/js/components/cashflow/breakdown-card.tsx index 3309a287..4bcf8885 100644 --- a/resources/js/components/cashflow/breakdown-card.tsx +++ b/resources/js/components/cashflow/breakdown-card.tsx @@ -1,7 +1,9 @@ import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; -import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator'; +import { + CategoryBreakdownRow, + type CategoryBreakdownAdapter, +} from '@/components/shared/category-breakdown-list'; import { AmountDisplay } from '@/components/ui/amount-display'; -import { AnimatedCollapse } from '@/components/ui/animated-collapse'; import { Card, CardContent, @@ -9,30 +11,19 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; -import { Progress } from '@/components/ui/progress'; 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 { useExpandableCategories } from '@/hooks/use-expandable-categories'; import { cn } from '@/lib/utils'; import { + getCategoryColorClasses, type CategoryColor, type CategoryIcon, - 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 { - ChevronsDown, - ChevronsUp, - Loader2, - LucideIcon, - Minus, -} from 'lucide-react'; +import { LucideIcon } from 'lucide-react'; import { useCallback } from 'react'; interface BreakdownCardProps { @@ -53,167 +44,6 @@ 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; -} - -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 = ( -
-
-
- -
- - {category.name} - -
-
- {percentageChange !== null && ( - - )} -
- - {item.percentage.toFixed(0)}% - - -
-
-
- ); - - return ( -
-
- {canExpand ? ( - - ) : ( - - )} - {categoryUrl ? ( - - {header} - - ) : ( -
{header}
- )} -
- - - {canExpand && ( - -
- {expandable.getChildren(id).map((child, childIndex) => ( - - ))} -
-
- )} -
- ); -} - export function BreakdownCard({ type, data, @@ -221,6 +51,8 @@ export function BreakdownCard({ currency = 'USD', period, }: BreakdownCardProps) { + const { categoryBarColor } = useChartColors(); + const title = type === 'income' ? __('Income Sources') : __('Expense Categories'); const description = @@ -262,6 +94,61 @@ export function BreakdownCard({ periodKey, ); + const adapter: CategoryBreakdownAdapter = { + getId: (item) => item.category_id ?? '', + getKey: (item) => rowKey(item), + getName: (item) => (item.category ?? fallbackCategory).name, + getAmount: (item) => item.amount, + getPercentage: (item) => item.percentage, + getBarColor: (item, index) => + categoryBarColor((item.category ?? fallbackCategory).color, index), + renderLeading: (item) => { + const category = item.category ?? fallbackCategory; + const color = getCategoryColorClasses(category.color); + const Icon = (Icons[category.icon as keyof typeof Icons] || + Icons.HelpCircle) as LucideIcon; + + return ( +
+ +
+ ); + }, + getHref: (item) => + 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, + getTrend: (item) => + item.previous_amount > 0 + ? { + change: + ((item.amount - item.previous_amount) / + item.previous_amount) * + 100, + previousAmount: item.previous_amount, + currentAmount: item.amount, + } + : null, + canExpand: (item) => + Boolean( + item.has_children && + !item.is_direct && + item.category_id && + period, + ), + }; + if (loading) { return ( @@ -305,14 +192,16 @@ export function BreakdownCard({
{data.data.map((item, index) => ( - ))} {data.data.length === 0 && ( diff --git a/resources/js/components/dashboard/top-categories-card.tsx b/resources/js/components/dashboard/top-categories-card.tsx index b8142549..90d4206c 100644 --- a/resources/js/components/dashboard/top-categories-card.tsx +++ b/resources/js/components/dashboard/top-categories-card.tsx @@ -1,6 +1,8 @@ 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 { + CategoryBreakdownRow, + type CategoryBreakdownAdapter, +} from '@/components/shared/category-breakdown-list'; import { Card, CardContent, @@ -8,32 +10,21 @@ import { CardHeader, CardTitle, } 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 { useExpandableCategories } from '@/hooks/use-expandable-categories'; import { cn } from '@/lib/utils'; import { SharedData } from '@/types'; import { Category, - type CategoryColor, getCategoryColorClasses, + type CategoryColor, } from '@/types/category'; import { __ } from '@/utils/i18n'; -import { Link, usePage } from '@inertiajs/react'; +import { usePage } from '@inertiajs/react'; import { format, subDays } from 'date-fns'; import * as Icons from 'lucide-react'; -import { - ChevronsDown, - ChevronsUp, - Loader2, - LucideIcon, - Minus, -} from 'lucide-react'; +import { LucideIcon } from 'lucide-react'; import { useCallback, useMemo } from 'react'; -import { PercentageTrendIndicator } from './percentage-trend-indicator'; interface CategoryData { category: Category | null; @@ -54,161 +45,12 @@ 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; -} - -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 = ( -
-
- -
- - {categoryName} - - {percentageChange !== null && ( - - )} - -
- ); - - return ( -
-
- {canExpand ? ( - - ) : ( - - )} - - {header} - -
- - - {canExpand && ( - -
- {expandable - .getChildren(categoryId) - .map((child, childIndex) => ( - - ))} -
-
- )} -
- ); -} - export function TopCategoriesCard({ categories, loading, }: TopCategoriesCardProps) { const { auth } = usePage().props; + const { categoryBarColor } = useChartColors(); const { dateFrom, dateTo } = useMemo(() => { const now = new Date(); @@ -238,6 +80,64 @@ export function TopCategoriesCard({ dateFrom, ); + const adapter: CategoryBreakdownAdapter = { + getId: (item) => + item.category?.id ?? item.category_id ?? 'uncategorized', + getKey: (item) => rowKey(item), + getName: (item) => item.category?.name ?? __('Uncategorized'), + getAmount: (item) => item.amount, + getPercentage: (item) => + item.total_amount > 0 ? (item.amount / item.total_amount) * 100 : 0, + getBarColor: (item, index) => + categoryBarColor( + (item.category?.color ?? 'gray') as CategoryColor, + index, + ), + renderLeading: (item) => { + const color = getCategoryColorClasses( + (item.category?.color ?? 'gray') as CategoryColor, + ); + const Icon = (Icons[ + (item.category?.icon ?? 'HelpCircle') as keyof typeof Icons + ] || Icons.HelpCircle) as LucideIcon; + + return ( +
+ +
+ ); + }, + getHref: (item) => + transactionsIndex({ + query: { + category_ids: + item.category?.id ?? + item.category_id ?? + 'uncategorized', + date_from: dateFrom, + date_to: dateTo, + }, + }).url, + getTrend: (item) => + item.previous_amount > 0 + ? { + change: + ((item.amount - item.previous_amount) / + item.previous_amount) * + 100, + previousAmount: item.previous_amount, + currentAmount: item.amount, + } + : null, + canExpand: (item) => + Boolean(item.has_children && !item.is_direct && item.category), + }; + if (loading || !auth?.user) { return ( @@ -271,14 +171,14 @@ export function TopCategoriesCard({
{categories.map((item, index) => ( - ))} {categories.length === 0 && ( diff --git a/resources/js/components/shared/category-breakdown-list.tsx b/resources/js/components/shared/category-breakdown-list.tsx new file mode 100644 index 00000000..d5e56cad --- /dev/null +++ b/resources/js/components/shared/category-breakdown-list.tsx @@ -0,0 +1,174 @@ +import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator'; +import { AmountDisplay } from '@/components/ui/amount-display'; +import { AnimatedCollapse } from '@/components/ui/animated-collapse'; +import { Progress } from '@/components/ui/progress'; +import { type ExpandableCategories } from '@/hooks/use-expandable-categories'; +import { __ } from '@/utils/i18n'; +import { Link } from '@inertiajs/react'; +import { ChevronsDown, ChevronsUp, Loader2, Minus } from 'lucide-react'; +import { type ReactNode } from 'react'; + +export interface BreakdownTrend { + change: number; + previousAmount: number; + currentAmount: number; +} + +/** + * Maps an arbitrary item to the fields a breakdown row renders. Each widget + * (dashboard categories, cash-flow income/expenses, the analysis drawer's + * categories, tags and accounts) supplies one of these so they can all share + * the exact same row: a leading marker, a truncated name, an optional trend + * and percentage, the amount, and a proportional bar underneath. + */ +export interface CategoryBreakdownAdapter { + getId: (item: T) => string; + getKey: (item: T, index: number) => string; + getName: (item: T) => string; + getAmount: (item: T) => number; + /** Bar fill, 0–100. */ + getPercentage: (item: T) => number; + getBarColor: (item: T, index: number) => string; + renderLeading: (item: T, index: number) => ReactNode; + getHref?: (item: T) => string | null; + getTrend?: (item: T) => BreakdownTrend | null; + canExpand?: (item: T) => boolean; +} + +interface CategoryBreakdownRowProps { + item: T; + index: number; + currencyCode: string; + adapter: CategoryBreakdownAdapter; + expandable?: ExpandableCategories; + /** Render the expand/collapse gutter (and its placeholder) on every row. */ + expandColumn?: boolean; + showPercentage?: boolean; + invertTrendColors?: boolean; +} + +export function CategoryBreakdownRow({ + item, + index, + currencyCode, + adapter, + expandable, + expandColumn = false, + showPercentage = false, + invertTrendColors = false, +}: CategoryBreakdownRowProps) { + const id = adapter.getId(item); + const percentage = adapter.getPercentage(item); + const href = adapter.getHref?.(item) ?? null; + const trend = adapter.getTrend?.(item) ?? null; + + const canExpand = Boolean(expandable && adapter.canExpand?.(item)); + const expanded = canExpand && expandable!.isExpanded(id); + const loading = canExpand && expandable!.isLoading(id); + + const header = ( +
+ {adapter.renderLeading(item, index)} + + {adapter.getName(item)} + + {trend && ( + + )} + {showPercentage && ( + + {percentage.toFixed(0)}% + + )} + +
+ ); + + return ( +
+
+ {expandColumn && + (canExpand ? ( + + ) : ( + + ))} + {href ? ( + + {header} + + ) : ( +
{header}
+ )} +
+ + + {canExpand && ( + +
+ {expandable! + .getChildren(id) + .map((child, childIndex) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/resources/js/components/transactions/transaction-analysis-drawer.test.tsx b/resources/js/components/transactions/transaction-analysis-drawer.test.tsx index 366b4f88..b1f4367a 100644 --- a/resources/js/components/transactions/transaction-analysis-drawer.test.tsx +++ b/resources/js/components/transactions/transaction-analysis-drawer.test.tsx @@ -6,6 +6,7 @@ import { waitFor, within, } from '@testing-library/react'; +import type React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TransactionAnalysisDrawer } from './transaction-analysis-drawer'; @@ -29,6 +30,13 @@ vi.mock('@/components/ui/amount-display', () => ({ ), })); +vi.mock('@inertiajs/react', () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), + usePage: () => ({ props: { chartColorScheme: 'colorful' } }), +})); + const filters: TransactionFilters = { dateFrom: null, dateTo: null, @@ -399,3 +407,106 @@ describe('TransactionAnalysisDrawer largest expenses columns', () => { expect(screen.getByText('Labels')).toBeInTheDocument(); }); }); + +describe('TransactionAnalysisDrawer breakdowns', () => { + const breakdownResponse = { + ...analysisResponse, + by_category: [ + { + category_id: 'c1', + name: 'Food', + color: 'amber', + icon: 'Utensils', + amount: 40000, + children: [ + { + category_id: 'c2', + name: 'Groceries', + color: 'amber', + icon: 'ShoppingBag', + amount: 30000, + }, + ], + }, + { + category_id: 'c3', + name: 'Hotel', + color: 'blue', + icon: 'Building', + amount: 20000, + children: [], + }, + ], + distinct_category_count: 2, + by_tag: [ + { id: 't1', name: 'Miami', color: 'blue', amount: 10000 }, + { id: 't2', name: 'Snacks', color: 'amber', amount: 3000 }, + ], + distinct_label_count: 2, + by_account: [ + { + id: 'a1', + name: 'Visa', + bank: { name: 'Acme', logo: null }, + amount: 5000, + }, + { id: 'a2', name: 'Amex', bank: null, amount: 3000 }, + ], + distinct_account_count: 2, + }; + + it('renders a category bar list and expands children on demand', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch(breakdownResponse); + + render( + , + ); + + await waitFor(() => + expect( + screen.getByText('Spending by category'), + ).toBeInTheDocument(), + ); + expect(screen.getByText('Food')).toBeInTheDocument(); + expect(screen.getByText('Hotel')).toBeInTheDocument(); + + // The nested category is hidden until its parent is expanded. + expect(screen.queryByText('Groceries')).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: 'Show subcategories' }), + ); + + await waitFor(() => + expect(screen.getByText('Groceries')).toBeInTheDocument(), + ); + }); + + it('renders the tag and account bar lists', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch(breakdownResponse); + + render( + , + ); + + await waitFor(() => + expect(screen.getByText('Spending by tag')).toBeInTheDocument(), + ); + expect(screen.getByText('Miami')).toBeInTheDocument(); + expect(screen.getByText('Snacks')).toBeInTheDocument(); + + expect(screen.getByText('Spending by account')).toBeInTheDocument(); + expect(screen.getByText('Visa')).toBeInTheDocument(); + expect(screen.getByText('Amex')).toBeInTheDocument(); + }); +}); diff --git a/resources/js/components/transactions/transaction-analysis-drawer.tsx b/resources/js/components/transactions/transaction-analysis-drawer.tsx index f1251b88..9bb17c75 100644 --- a/resources/js/components/transactions/transaction-analysis-drawer.tsx +++ b/resources/js/components/transactions/transaction-analysis-drawer.tsx @@ -1,5 +1,9 @@ import { AccountName } from '@/components/accounts/account-name'; import { BankLogo } from '@/components/bank-logo'; +import { + CategoryBreakdownRow, + type CategoryBreakdownAdapter, +} from '@/components/shared/category-breakdown-list'; import { LabelBadges } from '@/components/shared/label-combobox'; import { AmountDisplay } from '@/components/ui/amount-display'; import { Button } from '@/components/ui/button'; @@ -18,6 +22,8 @@ import { PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; +import { useChartColors } from '@/hooks/use-chart-color-scheme'; +import { useExpandableCategories } from '@/hooks/use-expandable-categories'; import { useLocale } from '@/hooks/use-locale'; import { filtersFingerprint, @@ -54,11 +60,8 @@ import { } from 'react'; import { Bar, - Cell, ComposedChart, Line, - Pie, - PieChart, ResponsiveContainer, Tooltip, XAxis, @@ -93,6 +96,7 @@ interface CategorySlice { category_id: string | null; name: string; color: string; + icon: string | null; amount: number; children: CategorySlice[]; } @@ -163,40 +167,6 @@ interface TransactionAnalysisDrawerProps { filters: TransactionFilters; } -const CHART_PALETTE = [ - 'var(--color-chart-1)', - 'var(--color-chart-2)', - 'var(--color-chart-3)', - 'var(--color-chart-4)', - 'var(--color-chart-5)', - 'var(--color-chart-6)', - 'var(--color-chart-7)', - 'var(--color-chart-8)', -]; - -/** - * Reorders a sequential palette so neighbouring entries alternate between its - * dark and light ends. Monochrome schemes (blue, pink, neutral) run from - * darkest to lightest, so picking adjacent shades leaves consecutive slices - * nearly indistinguishable; zipping the two halves maximises the contrast - * between one slice and the next. - */ -function alternateContrast(palette: string[]): string[] { - const half = Math.ceil(palette.length / 2); - const result: string[] = []; - - for (let index = 0; index < half; index++) { - result.push(palette[index]); - if (index + half < palette.length) { - result.push(palette[index + half]); - } - } - - return result; -} - -const CHART_COLORS = alternateContrast(CHART_PALETTE); - function buildQueryString(filters: SerializedFilters): string { const params = new URLSearchParams(); @@ -515,7 +485,6 @@ export function TransactionAnalysisDrawer({ )}
@@ -1078,7 +1047,7 @@ function LargestTransactions({ )} {showCategory && ( - + @@ -1113,7 +1082,7 @@ function LargestTransactions({ )} )} - + {item.description || ( — @@ -1130,7 +1099,7 @@ function LargestTransactions({ /> )} - + - - {category.name} + + {category.name} ); } +/** + * Builds the icon-in-a-coloured-circle marker the dashboard uses for a + * category, reused for every category row in the drawer's breakdowns. + */ +function categoryLeading(color: string | null, icon: string | null): ReactNode { + const classes = getCategoryColorClasses((color ?? 'gray') as CategoryColor); + const Icon = (Icons[(icon ?? 'HelpCircle') as CategoryIcon] ?? + HelpCircle) as LucideIcon; + + return ( +
+ +
+ ); +} + function CategoryBreakdown({ slices, currency, @@ -1188,120 +1179,50 @@ function CategoryBreakdown({ slices: CategorySlice[]; currency: string; }) { + const { categoryBarColor } = useChartColors(); const total = slices.reduce((sum, slice) => sum + slice.amount, 0); - const config: ChartConfig = { amount: { label: __('Spent') } }; + + const childrenById = useMemo(() => { + const map: Record = {}; + for (const slice of slices) { + if (slice.category_id) { + map[slice.category_id] = slice.children ?? []; + } + } + return map; + }, [slices]); + + const expandable = useExpandableCategories( + async (categoryId) => childrenById[categoryId] ?? [], + slices, + ); + + const adapter: CategoryBreakdownAdapter = { + getId: (item) => item.category_id ?? '', + getKey: (item, index) => item.category_id ?? `category-${index}`, + getName: (item) => item.name, + getAmount: (item) => item.amount, + getPercentage: (item) => (total > 0 ? (item.amount / total) * 100 : 0), + getBarColor: (item, index) => + categoryBarColor((item.color ?? 'gray') as CategoryColor, index), + renderLeading: (item) => categoryLeading(item.color, item.icon), + canExpand: (item) => (item.children?.length ?? 0) > 0, + }; return ( -
- - - - - {slices.map((slice, index) => ( - - ))} - - - - - -
    - {slices.map((slice, index) => { - const color = CHART_COLORS[index % CHART_COLORS.length]; - - return ( -
  • -
    - - - {slice.name} - - - {total > 0 - ? Math.round( - (slice.amount / total) * 100, - ) - : 0} - % - - -
    - - {slice.children.length > 0 && ( -
      - {slice.children.map( - (child, childIndex) => ( -
    • - - - {child.name} - - - {slice.amount > 0 - ? Math.round( - (child.amount / - slice.amount) * - 100, - ) - : 0} - % - - -
    • - ), - )} -
    - )} -
  • - ); - })} -
+
+ {slices.map((slice, index) => ( + + ))}
); @@ -1373,20 +1294,48 @@ function HorizontalBarBreakdown({ function TagBreakdown({ slices, currency, - locale, }: { slices: TagSlice[]; currency: string; - locale: string; }) { + const { categoryBarColor } = useChartColors(); + const total = slices.reduce((sum, slice) => sum + slice.amount, 0); + + const adapter: CategoryBreakdownAdapter = { + getId: (item) => item.id, + getKey: (item, index) => item.id ?? `tag-${index}`, + getName: (item) => item.name, + getAmount: (item) => item.amount, + getPercentage: (item) => (total > 0 ? (item.amount / total) * 100 : 0), + getBarColor: (item, index) => + categoryBarColor((item.color ?? 'gray') as CategoryColor, index), + renderLeading: (item, index) => ( + + ), + }; + return ( - + +
+ {slices.map((slice, index) => ( + + ))} +
+
); } @@ -1410,6 +1359,17 @@ function PayeeBreakdown({ ); } +const ACCOUNT_BAR_COLORS = [ + 'var(--color-chart-1)', + 'var(--color-chart-2)', + 'var(--color-chart-3)', + 'var(--color-chart-4)', + 'var(--color-chart-5)', + 'var(--color-chart-6)', + 'var(--color-chart-7)', + 'var(--color-chart-8)', +]; + function AccountBreakdown({ slices, currency, @@ -1419,37 +1379,37 @@ function AccountBreakdown({ }) { const total = slices.reduce((sum, slice) => sum + slice.amount, 0); + const adapter: CategoryBreakdownAdapter = { + getId: (item) => item.id ?? '', + getKey: (item, index) => item.id ?? `account-${index}`, + getName: (item) => item.name, + getAmount: (item) => item.amount, + getPercentage: (item) => (total > 0 ? (item.amount / total) * 100 : 0), + getBarColor: (_item, index) => + ACCOUNT_BAR_COLORS[index % ACCOUNT_BAR_COLORS.length], + renderLeading: (item) => ( + + ), + }; + return ( -
    +
    {slices.map((slice, index) => ( -
  • - - - {slice.name} - - - {total > 0 - ? Math.round((slice.amount / total) * 100) - : 0} - % - - -
  • + item={slice} + index={index} + currencyCode={currency} + adapter={adapter} + /> ))} -
+
); } diff --git a/tests/Feature/TransactionAnalysisTest.php b/tests/Feature/TransactionAnalysisTest.php index b26f0b21..2e33bfd9 100644 --- a/tests/Feature/TransactionAnalysisTest.php +++ b/tests/Feature/TransactionAnalysisTest.php @@ -75,7 +75,7 @@ test('summary totals income, expense, net and count from the filtered set', func }); test('category breakdown groups expenses by top-level category', function () { - $hotel = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Hotel']); + $hotel = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Hotel', 'color' => 'blue', 'icon' => 'Building']); $meals = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Meals']); makeTransaction(['amount' => -50000, 'category_id' => $hotel->id, 'transaction_date' => '2026-01-10']); @@ -85,7 +85,7 @@ test('category breakdown groups expenses by top-level category', function () { $response->assertOk(); expect($response->json('distinct_category_count'))->toBe(2); - expect($response->json('by_category.0'))->toMatchArray(['name' => 'Hotel', 'amount' => 50000, 'children' => []]); + expect($response->json('by_category.0'))->toMatchArray(['name' => 'Hotel', 'amount' => 50000, 'color' => 'blue', 'icon' => 'Building', 'children' => []]); expect($response->json('by_category.1'))->toMatchArray(['name' => 'Meals', 'amount' => 20000, 'children' => []]); });