whisper-money/resources/js/components/cashflow/breakdown-card.tsx

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>
);
}