359 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|