274 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|