whisper-money/resources/js/components/charts/cashflow-trend-chart.tsx

274 lines
9.7 KiB
TypeScript

import { AmountDisplay } from '@/components/ui/amount-display';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
import { TrendDataPoint } from '@/hooks/use-cashflow-data';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { formatCompactNumber, formatMonthFromYearMonth } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { useEffect, useRef } from 'react';
import {
Bar,
BarChart,
CartesianGrid,
Line,
ReferenceLine,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface CashflowTrendChartProps {
data: TrendDataPoint[];
loading?: boolean;
className?: string;
currency?: string;
}
interface TooltipPayloadItem {
dataKey?: string;
value?: number;
color?: string;
name?: string;
payload?: TrendDataPoint;
}
interface CustomTooltipProps {
active?: boolean;
payload?: TooltipPayloadItem[];
currency?: string;
}
function CustomTooltip({
active,
payload,
currency = 'USD',
}: CustomTooltipProps) {
const locale = useLocale();
if (!active || !payload?.length) return null;
const data = payload[0].payload;
if (!data) return null;
return (
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
<div className="mb-2 font-medium">
{formatMonthFromYearMonth(data.month, locale)}
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<span className="size-2 rounded-full bg-[var(--color-chart-2)]" />
{__('Income')}
</span>
<AmountDisplay
amountInCents={data.income}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
className="font-mono font-medium tabular-nums"
/>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<span className="size-2 rounded-full bg-[var(--color-chart-5)]" />
{__('Expenses')}
</span>
<AmountDisplay
amountInCents={data.expense}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
className="font-mono font-medium tabular-nums"
/>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-1">
<span className="flex items-center gap-2">
<span className="size-2 rounded-full bg-[var(--color-chart-1)]" />
{__('Net')}
</span>
<AmountDisplay
amountInCents={data.net}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
className={cn(
'font-mono font-medium tabular-nums',
data.net >= 0 ? 'text-green-600' : 'text-red-600',
)}
/>
</div>
</div>
</div>
);
}
export function CashflowTrendChart({
data,
loading,
className,
currency = 'USD',
}: CashflowTrendChartProps) {
const locale = useLocale();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minChartWidth = data.length * 60;
const chartConfig: ChartConfig = {
income: {
label: __('Income'),
color: 'var(--color-chart-2)',
},
expense: {
label: __('Expenses'),
color: 'var(--color-chart-5)',
},
net: {
label: __('Net'),
color: 'var(--color-chart-1)',
},
};
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft =
scrollContainerRef.current.scrollWidth;
}
}, [data]);
if (loading) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-base">
{__('Cashflow Trend')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[250px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader className="gap-1 pb-4">
<CardTitle className="text-base">
{__('Cashflow Trend')}
</CardTitle>
<CardDescription>
{__(
'Monthly income, expenses, and net cashflow over the last 12 months',
)}
</CardDescription>
</CardHeader>
<CardContent>
<div ref={scrollContainerRef} className="overflow-x-auto">
<ChartContainer
config={chartConfig}
className="h-[250px] w-full"
style={{ minWidth: `${minChartWidth}px` }}
>
<BarChart accessibilityLayer data={data}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="var(--color-border)"
opacity={0.3}
/>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) =>
formatMonthFromYearMonth(value, locale)
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value: number) =>
formatCompactNumber(
value / 100,
locale,
currency,
)
}
width={60}
/>
<ReferenceLine
y={0}
stroke="var(--color-border)"
strokeDasharray="3 3"
/>
<Tooltip
content={<CustomTooltip currency={currency} />}
cursor={{
fill: 'var(--color-muted)',
opacity: 0.3,
}}
/>
<Bar
dataKey="income"
fill="var(--color-chart-2)"
radius={[4, 4, 0, 0]}
stackId="a"
name="Income"
/>
<Bar
dataKey="expense"
fill="var(--color-chart-5)"
radius={[4, 4, 0, 0]}
stackId="b"
name="Expenses"
/>
<Line
type="monotone"
dataKey="net"
stroke="var(--color-chart-1)"
strokeWidth={2}
dot={{
fill: 'var(--color-chart-1)',
strokeWidth: 0,
r: 3,
}}
activeDot={{ r: 5 }}
name="Net"
/>
</BarChart>
</ChartContainer>
</div>
<div className="mt-4 flex items-center justify-center gap-6 text-xs">
<div className="flex items-center gap-2">
<span className="size-3 rounded bg-[var(--color-chart-2)]" />
<span>{__('Income')}</span>
</div>
<div className="flex items-center gap-2">
<span className="size-3 rounded bg-[var(--color-chart-5)]" />
<span>{__('Expenses')}</span>
</div>
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-[var(--color-chart-1)]" />
<span>{__('Net')}</span>
</div>
</div>
</CardContent>
</Card>
);
}