whisper-money/resources/js/components/budgets/budget-spending-chart.tsx

359 lines
13 KiB
TypeScript

import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartContainer,
ChartTooltip,
type ChartConfig,
} from '@/components/ui/chart';
import { useLocale } from '@/hooks/use-locale';
import { BudgetPeriod } from '@/types/budget';
import { formatCurrency } from '@/utils/currency';
import { formatDate } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { useMemo } from 'react';
import { Area, AreaChart, Line, XAxis } from 'recharts';
interface Props {
currentPeriod: BudgetPeriod;
previousPeriod?: BudgetPeriod | null;
budgetName: string;
currencyCode: string;
}
interface ChartDataPoint {
day: number;
date: string;
spent: number;
allocated: number;
remaining: number;
prevSpent?: number;
prevDate?: string;
}
interface CustomTooltipProps {
active?: boolean;
payload?: Array<{
payload: ChartDataPoint;
}>;
label?: string | number;
currencyCode: string;
hasPreviousPeriod: boolean;
locale: string;
}
function CustomTooltip({
active,
payload,
currencyCode,
hasPreviousPeriod,
locale,
}: CustomTooltipProps) {
if (!active || !payload || !payload.length) {
return null;
}
const data = payload[0].payload;
const allocated = data.allocated;
const spent = data.spent;
const available = data.remaining;
const percentage =
allocated > 0 ? Math.round((available / allocated) * 100) : 0;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="mb-2 text-sm font-medium">
{hasPreviousPeriod
? `Day ${data.day}`
: formatDate(data.date, 'MMM d, yyyy', locale)}
</p>
<div className="space-y-1 text-sm">
<div className="flex items-center justify-between gap-8">
<span className="text-muted-foreground">
{__('Allocated:')}
</span>
<span className="font-medium">
{formatCurrency(allocated, currencyCode)}
</span>
</div>
<div className="flex items-center justify-between gap-8">
<span className="text-muted-foreground">
{__('Spent:')}
</span>
<span className="font-medium">
{formatCurrency(spent, currencyCode)}
</span>
</div>
{hasPreviousPeriod && data.prevSpent !== undefined && (
<div className="flex items-center justify-between gap-8">
<span className="text-muted-foreground">
{__('Last period:')}
</span>
<span className="font-medium text-muted-foreground">
{formatCurrency(data.prevSpent, currencyCode)}
</span>
</div>
)}
<div className="border-t pt-1">
<div className="flex items-center justify-between gap-8">
<span className="font-medium">{__('Available:')}</span>
<div className="flex items-baseline gap-1">
<span className="text-xs text-muted-foreground">
{percentage}% /
</span>
<span className="font-semibold">
{formatCurrency(available, currencyCode)}
</span>
</div>
</div>
</div>
</div>
</div>
);
}
function buildCumulativeSpending(period: BudgetPeriod): Map<string, number> {
const transactions = period.budget_transactions || [];
const transactionsByDate = new Map<string, number>();
transactions.forEach((t) => {
if (!t.transaction) return;
const date = new Date(t.transaction.transaction_date)
.toISOString()
.split('T')[0];
transactionsByDate.set(
date,
(transactionsByDate.get(date) || 0) + t.amount,
);
});
return transactionsByDate;
}
export function BudgetSpendingChart({
currentPeriod,
previousPeriod,
budgetName,
currencyCode,
}: Props) {
const locale = useLocale();
const hasPreviousPeriod = !!previousPeriod;
const chartData = useMemo(() => {
const currentByDate = buildCumulativeSpending(currentPeriod);
const prevByDate = previousPeriod
? buildCumulativeSpending(previousPeriod)
: null;
const startDate = new Date(currentPeriod.start_date);
const endDate = new Date(currentPeriod.end_date);
const prevStartDate = previousPeriod
? new Date(previousPeriod.start_date)
: null;
const prevEndDate = previousPeriod
? new Date(previousPeriod.end_date)
: null;
const data: ChartDataPoint[] = [];
let cumulativeSpent = 0;
let prevCumulativeSpent = 0;
const currentDate = new Date(startDate);
let dayIndex = 1;
while (currentDate <= endDate) {
const dateStr = currentDate.toISOString().split('T')[0];
const dailySpent = currentByDate.get(dateStr) || 0;
cumulativeSpent += dailySpent;
const point: ChartDataPoint = {
day: dayIndex,
date: dateStr,
spent: cumulativeSpent,
allocated: currentPeriod.allocated_amount,
remaining: currentPeriod.allocated_amount - cumulativeSpent,
};
// Map to the same day index in the previous period
if (prevByDate && prevStartDate && prevEndDate) {
const prevDate = new Date(prevStartDate);
prevDate.setDate(prevDate.getDate() + dayIndex - 1);
if (prevDate <= prevEndDate) {
const prevDateStr = prevDate.toISOString().split('T')[0];
const prevDailySpent = prevByDate.get(prevDateStr) || 0;
prevCumulativeSpent += prevDailySpent;
point.prevSpent = prevCumulativeSpent;
point.prevDate = prevDateStr;
}
}
data.push(point);
currentDate.setDate(currentDate.getDate() + 1);
dayIndex++;
}
return data;
}, [currentPeriod, previousPeriod]);
const chartConfig = {
spent: {
label: 'Spent',
color: 'var(--spent)',
},
allocated: {
label: 'Budget',
color: 'var(--allocated)',
},
...(hasPreviousPeriod && {
prevSpent: {
label: 'Last Period',
color: 'var(--spent-prev)',
},
}),
} satisfies ChartConfig;
const periodLabel = useMemo(() => {
const start = formatDate(currentPeriod.start_date, 'MMM d', locale);
const end = formatDate(currentPeriod.end_date, 'MMM d, yyyy', locale);
return `${start} - ${end}`;
}, [currentPeriod, locale]);
return (
<Card>
<CardHeader>
<CardTitle>{__('Budget Spending')}</CardTitle>
<CardDescription>
{__('Tracking spending for')}
{budgetName} · {periodLabel}
</CardDescription>
</CardHeader>
<CardContent className="px-6 pt-0">
<ChartContainer
config={chartConfig}
className="h-[300px] w-full"
>
<AreaChart
className="overflow-hidden rounded"
data={chartData}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="fillSpent"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--color-spent)"
stopOpacity={0.8}
/>
<stop
offset="50%"
stopColor="var(--color-spent)"
stopOpacity={0.4}
/>
<stop
offset="100%"
stopColor="var(--color-spent)"
stopOpacity={0.05}
/>
</linearGradient>
<linearGradient
id="fillAllocated"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--color-allocated)"
stopOpacity={0.4}
/>
<stop
offset="50%"
stopColor="var(--color-allocated)"
stopOpacity={0.2}
/>
<stop
offset="100%"
stopColor="var(--color-allocated)"
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<XAxis
dataKey={hasPreviousPeriod ? 'day' : 'date'}
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => {
if (hasPreviousPeriod) {
return `Day ${value}`;
}
return formatDate(value, 'MMM d', locale);
}}
/>
<ChartTooltip
content={
<CustomTooltip
currencyCode={currencyCode}
hasPreviousPeriod={hasPreviousPeriod}
locale={locale}
/>
}
/>
<Area
dataKey="allocated"
type="basis"
fill="url(#fillAllocated)"
stroke="var(--color-allocated)"
strokeWidth={2}
dot={false}
activeDot={{ r: 6 }}
fillOpacity={1}
/>
<Area
dataKey="spent"
type="basis"
fill="url(#fillSpent)"
stroke="var(--color-spent)"
strokeWidth={2}
dot={false}
activeDot={{ r: 6 }}
fillOpacity={1}
/>
{hasPreviousPeriod && (
<Line
dataKey="prevSpent"
type="basis"
stroke="var(--color-prevSpent)"
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
activeDot={{ r: 4 }}
connectNulls={false}
/>
)}
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}