feat(analysis): shared bar-list breakdowns in transaction drawer (#517)
## What Unifies the "top categories"-style bar lists behind one reusable component and applies it to the transaction **analysis drawer**. ### Shared component New `resources/js/components/shared/category-breakdown-list.tsx` — generic `CategoryBreakdownRow<T>` + adapter (leading marker, truncated name, optional trend + %, amount, proportional bar, expand/collapse recursion). Now used by: - Dashboard **Top spending categories** (was a local `CategoryRow`) - Cash-flow **Income Sources / Expense Categories** (was a local `BreakdownRow`) - Analysis drawer breakdowns ### Analysis drawer - **Top categories** — pie+list → bar list, now with **expand/collapse** of subcategories (children already in the payload, served through `useExpandableCategories` with no extra fetch). - **Top tags** — recharts bar → bar list, colour-dot leading marker. - **Top accounts** — plain list → bar list, with the **bank/account logo** as the leading marker instead of a category icon. ### Largest expenses table Long category names pushed the amount onto two lines (minus sign split from the digits). Category + description columns are now capped to the same width, the chip truncates, and the amount cell is `whitespace-nowrap`. ### Backend `icon` added to the category breakdown payload (parent, child, Uncategorized) so the drawer can render the icon circle. ## Tests - Drawer: category expand/collapse + tag/account render tests. - Backend: asserts `color`/`icon` on the category breakdown. - Full JS suite (191) + analysis feature tests green; pint / eslint / prettier clean. ## Note Dashboard card has no test file — refactor preserves its public behaviour; worth a visual check.
This commit is contained in:
parent
fcf2d3d1ad
commit
bcd025f1b1
|
|
@ -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' => [],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
|
|
@ -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<BreakdownItem> = {
|
||||
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 (
|
||||
<div
|
||||
className={cn([
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
`${color.bg} ${color.text}`,
|
||||
])}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
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 (
|
||||
<Card>
|
||||
|
|
@ -305,14 +192,16 @@ export function BreakdownCard({
|
|||
<CardContent>
|
||||
<div className="space-y-2.5">
|
||||
{data.data.map((item, index) => (
|
||||
<BreakdownRow
|
||||
<CategoryBreakdownRow
|
||||
key={rowKey(item)}
|
||||
item={item}
|
||||
index={index}
|
||||
type={type}
|
||||
currency={currency}
|
||||
period={period}
|
||||
currencyCode={currency}
|
||||
adapter={adapter}
|
||||
expandable={expandable}
|
||||
expandColumn
|
||||
showPercentage
|
||||
invertTrendColors={type === 'expense'}
|
||||
/>
|
||||
))}
|
||||
{data.data.length === 0 && (
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
|
|
@ -238,6 +80,64 @@ export function TopCategoriesCard({
|
|||
dateFrom,
|
||||
);
|
||||
|
||||
const adapter: CategoryBreakdownAdapter<CategoryData> = {
|
||||
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 (
|
||||
<div
|
||||
className={cn([
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
`${color.bg} ${color.text}`,
|
||||
])}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
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 (
|
||||
<Card className="w-full">
|
||||
|
|
@ -271,14 +171,14 @@ export function TopCategoriesCard({
|
|||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{categories.map((item, index) => (
|
||||
<CategoryRow
|
||||
<CategoryBreakdownRow
|
||||
key={rowKey(item)}
|
||||
item={item}
|
||||
index={index}
|
||||
currencyCode={auth.user.currency_code}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
adapter={adapter}
|
||||
expandable={expandable}
|
||||
expandColumn
|
||||
/>
|
||||
))}
|
||||
{categories.length === 0 && (
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
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<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
currencyCode: string;
|
||||
adapter: CategoryBreakdownAdapter<T>;
|
||||
expandable?: ExpandableCategories<T>;
|
||||
/** Render the expand/collapse gutter (and its placeholder) on every row. */
|
||||
expandColumn?: boolean;
|
||||
showPercentage?: boolean;
|
||||
invertTrendColors?: boolean;
|
||||
}
|
||||
|
||||
export function CategoryBreakdownRow<T>({
|
||||
item,
|
||||
index,
|
||||
currencyCode,
|
||||
adapter,
|
||||
expandable,
|
||||
expandColumn = false,
|
||||
showPercentage = false,
|
||||
invertTrendColors = false,
|
||||
}: CategoryBreakdownRowProps<T>) {
|
||||
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 = (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{adapter.renderLeading(item, index)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{adapter.getName(item)}
|
||||
</span>
|
||||
{trend && (
|
||||
<PercentageTrendIndicator
|
||||
trend={trend.change}
|
||||
label=""
|
||||
previousAmount={trend.previousAmount}
|
||||
currentAmount={trend.currentAmount}
|
||||
currencyCode={currencyCode}
|
||||
invertColors={invertTrendColors}
|
||||
className="shrink-0 text-xs"
|
||||
/>
|
||||
)}
|
||||
{showPercentage && (
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
|
||||
{percentage.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
<AmountDisplay
|
||||
amountInCents={adapter.getAmount(item)}
|
||||
currencyCode={currencyCode}
|
||||
variant="compact"
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-0">
|
||||
{expandColumn &&
|
||||
(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>
|
||||
))}
|
||||
{href ? (
|
||||
<Link
|
||||
href={href}
|
||||
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={percentage}
|
||||
className="h-2 w-full"
|
||||
indicatorColor={adapter.getBarColor(item, index)}
|
||||
/>
|
||||
|
||||
{canExpand && (
|
||||
<AnimatedCollapse open={expanded}>
|
||||
<div className="ml-[11px] space-y-2 border-l border-border pt-2 pl-3">
|
||||
{expandable!
|
||||
.getChildren(id)
|
||||
.map((child, childIndex) => (
|
||||
<CategoryBreakdownRow
|
||||
key={adapter.getKey(child, childIndex)}
|
||||
item={child}
|
||||
index={childIndex}
|
||||
currencyCode={currencyCode}
|
||||
adapter={adapter}
|
||||
expandable={expandable}
|
||||
expandColumn={expandColumn}
|
||||
showPercentage={showPercentage}
|
||||
invertTrendColors={invertTrendColors}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AnimatedCollapse>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
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(
|
||||
<TransactionAnalysisDrawer
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
filters={filters}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TransactionAnalysisDrawer
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
filters={filters}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<TagBreakdown
|
||||
slices={data.by_tag}
|
||||
currency={currency}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1078,7 +1047,7 @@ function LargestTransactions({
|
|||
)}
|
||||
</td>
|
||||
{showCategory && (
|
||||
<td className="py-2 pr-3">
|
||||
<td className="max-w-[160px] py-2 pr-3">
|
||||
<CategoryChip
|
||||
category={item.category}
|
||||
/>
|
||||
|
|
@ -1113,7 +1082,7 @@ function LargestTransactions({
|
|||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="max-w-[200px] truncate py-2 pr-3">
|
||||
<td className="max-w-[160px] truncate py-2 pr-3">
|
||||
{item.description || (
|
||||
<span className="text-muted-foreground">
|
||||
—
|
||||
|
|
@ -1130,7 +1099,7 @@ function LargestTransactions({
|
|||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2 text-right">
|
||||
<td className="py-2 text-right whitespace-nowrap">
|
||||
<AmountDisplay
|
||||
amountInCents={-item.amount}
|
||||
currencyCode={currency}
|
||||
|
|
@ -1170,17 +1139,39 @@ function CategoryChip({ category }: { category: LargestExpense['category'] }) {
|
|||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-xs whitespace-nowrap',
|
||||
'inline-flex max-w-full items-center gap-1.5 rounded-md px-1.5 py-0.5 text-xs',
|
||||
classes.bg,
|
||||
classes.text,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{category.name}
|
||||
<Icon className="size-3 shrink-0" />
|
||||
<span className="truncate">{category.name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
classes.bg,
|
||||
classes.text,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, CategorySlice[]> = {};
|
||||
for (const slice of slices) {
|
||||
if (slice.category_id) {
|
||||
map[slice.category_id] = slice.children ?? [];
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [slices]);
|
||||
|
||||
const expandable = useExpandableCategories<CategorySlice>(
|
||||
async (categoryId) => childrenById[categoryId] ?? [],
|
||||
slices,
|
||||
);
|
||||
|
||||
const adapter: CategoryBreakdownAdapter<CategorySlice> = {
|
||||
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 (
|
||||
<Panel title={__('Spending by category')}>
|
||||
<div className="flex flex-col items-center gap-6 sm:flex-row">
|
||||
<ChartContainer config={config} className="h-52 w-52 shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={slices}
|
||||
dataKey="amount"
|
||||
nameKey="name"
|
||||
innerRadius={55}
|
||||
outerRadius={85}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{slices.map((slice, index) => (
|
||||
<Cell
|
||||
key={
|
||||
slice.category_id ??
|
||||
`slice-${index}`
|
||||
}
|
||||
fill={
|
||||
CHART_COLORS[
|
||||
index % CHART_COLORS.length
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
|
||||
<ul className="flex w-full flex-col gap-3">
|
||||
{slices.map((slice, index) => {
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
|
||||
return (
|
||||
<li
|
||||
key={slice.category_id ?? `row-${index}`}
|
||||
className="flex flex-col gap-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span
|
||||
className="h-3 w-3 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="flex-1 truncate font-medium">
|
||||
{slice.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{total > 0
|
||||
? Math.round(
|
||||
(slice.amount / total) * 100,
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</span>
|
||||
<AmountDisplay
|
||||
amountInCents={slice.amount}
|
||||
currencyCode={currency}
|
||||
className="font-mono tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{slice.children.length > 0 && (
|
||||
<ul className="flex flex-col gap-1 pl-6">
|
||||
{slice.children.map(
|
||||
(child, childIndex) => (
|
||||
<li
|
||||
key={
|
||||
child.category_id ??
|
||||
`sub-${index}-${childIndex}`
|
||||
}
|
||||
className="flex items-center gap-2.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full opacity-60"
|
||||
style={{
|
||||
backgroundColor:
|
||||
color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 truncate">
|
||||
{child.name}
|
||||
</span>
|
||||
<span>
|
||||
{slice.amount > 0
|
||||
? Math.round(
|
||||
(child.amount /
|
||||
slice.amount) *
|
||||
100,
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</span>
|
||||
<AmountDisplay
|
||||
amountInCents={
|
||||
child.amount
|
||||
}
|
||||
currencyCode={currency}
|
||||
className="font-mono tabular-nums"
|
||||
/>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex flex-col gap-3">
|
||||
{slices.map((slice, index) => (
|
||||
<CategoryBreakdownRow
|
||||
key={slice.category_id ?? `category-${index}`}
|
||||
item={slice}
|
||||
index={index}
|
||||
currencyCode={currency}
|
||||
adapter={adapter}
|
||||
expandable={expandable}
|
||||
expandColumn
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
|
|
@ -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<TagSlice> = {
|
||||
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) => (
|
||||
<span
|
||||
className="size-3 shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: categoryBarColor(
|
||||
(item.color ?? 'gray') as CategoryColor,
|
||||
index,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<HorizontalBarBreakdown
|
||||
title={__('Spending by tag')}
|
||||
data={slices}
|
||||
currency={currency}
|
||||
locale={locale}
|
||||
color="var(--color-chart-1)"
|
||||
/>
|
||||
<Panel title={__('Spending by tag')}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{slices.map((slice, index) => (
|
||||
<CategoryBreakdownRow
|
||||
key={slice.id ?? `tag-${index}`}
|
||||
item={slice}
|
||||
index={index}
|
||||
currencyCode={currency}
|
||||
adapter={adapter}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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<AccountSlice> = {
|
||||
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) => (
|
||||
<BankLogo
|
||||
src={item.bank?.logo}
|
||||
name={item.bank?.name}
|
||||
className="size-6 shrink-0"
|
||||
fallback="icon"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel title={__('Spending by account')}>
|
||||
<ul className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{slices.map((slice, index) => (
|
||||
<li
|
||||
<CategoryBreakdownRow
|
||||
key={slice.id ?? `account-${index}`}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<BankLogo
|
||||
src={slice.bank?.logo}
|
||||
name={slice.bank?.name}
|
||||
className="h-5 w-5"
|
||||
fallback="icon"
|
||||
/>
|
||||
<span className="flex-1 truncate font-medium">
|
||||
{slice.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{total > 0
|
||||
? Math.round((slice.amount / total) * 100)
|
||||
: 0}
|
||||
%
|
||||
</span>
|
||||
<AmountDisplay
|
||||
amountInCents={slice.amount}
|
||||
currencyCode={currency}
|
||||
className="font-mono tabular-nums"
|
||||
/>
|
||||
</li>
|
||||
item={slice}
|
||||
index={index}
|
||||
currencyCode={currency}
|
||||
adapter={adapter}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' => []]);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue