213 lines
9.3 KiB
TypeScript
213 lines
9.3 KiB
TypeScript
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 {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import { BreakdownData } from '@/hooks/use-cashflow-data';
|
|
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
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 { LucideIcon } from 'lucide-react';
|
|
|
|
interface BreakdownCardProps {
|
|
type: 'income' | 'expense';
|
|
data: BreakdownData;
|
|
loading?: boolean;
|
|
currency?: string;
|
|
period?: { from: Date; to: Date };
|
|
}
|
|
|
|
const fallbackCategory = {
|
|
name: __('Uncategorized'),
|
|
icon: 'HelpCircle' as CategoryIcon,
|
|
color: 'gray' as CategoryColor,
|
|
};
|
|
|
|
export function BreakdownCard({
|
|
type,
|
|
data,
|
|
loading,
|
|
currency = 'USD',
|
|
period,
|
|
}: BreakdownCardProps) {
|
|
const { categoryBarColor } = useChartColors();
|
|
const title =
|
|
type === 'income' ? __('Income Sources') : __('Expense Categories');
|
|
const description =
|
|
type === 'income'
|
|
? __('Where your money comes from')
|
|
: __('Where your money goes');
|
|
const emptyMessage =
|
|
type === 'income'
|
|
? __('No income this period')
|
|
: __('No expenses this period');
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">{title}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<div className="flex items-center gap-3">
|
|
<div className="size-6 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
|
|
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
|
<div className="ml-auto h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
|
</div>
|
|
<div className="h-2 w-full animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="gap-1 pb-4">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">{title}</CardTitle>
|
|
<AmountDisplay
|
|
amountInCents={data.total}
|
|
currencyCode={currency}
|
|
minimumFractionDigits={0}
|
|
maximumFractionDigits={0}
|
|
weight="semibold"
|
|
highlightPositive
|
|
/>
|
|
</div>
|
|
<CardDescription>{description}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{data.data.map((item, index) => {
|
|
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 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
|
|
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 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>
|
|
<Progress
|
|
value={item.percentage}
|
|
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>
|
|
);
|
|
})}
|
|
{data.data.length === 0 && (
|
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
{emptyMessage}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|