314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import {
|
|
ChartViewToggle,
|
|
MoMChart,
|
|
MoMPercentChart,
|
|
} from '@/components/charts';
|
|
import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator';
|
|
import { EncryptedText } from '@/components/encrypted-text';
|
|
import { AmountDisplay } from '@/components/ui/amount-display';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import {
|
|
ChartConfig,
|
|
ChartContainer,
|
|
ChartTooltip,
|
|
ChartTooltipContent,
|
|
} from '@/components/ui/chart';
|
|
import {
|
|
convertSingleAccountData,
|
|
useChartViews,
|
|
} from '@/hooks/use-chart-views';
|
|
import { Account } from '@/types/account';
|
|
import { format, subMonths } from 'date-fns';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { Bar, BarChart, XAxis } from 'recharts';
|
|
|
|
interface BalanceDataPoint {
|
|
month: string;
|
|
timestamp: number;
|
|
value: number;
|
|
}
|
|
|
|
interface AccountBalanceData {
|
|
data: BalanceDataPoint[];
|
|
account: {
|
|
id: string;
|
|
name: string;
|
|
name_iv: string;
|
|
type: string;
|
|
currency_code: string;
|
|
};
|
|
}
|
|
|
|
interface AccountBalanceChartProps {
|
|
account: Account;
|
|
loading?: boolean;
|
|
refreshKey?: number;
|
|
onBalanceClick?: () => void;
|
|
}
|
|
|
|
function formatXAxisLabel(value: string): string {
|
|
const [year, month] = value.split('-');
|
|
const date = new Date(parseInt(year), parseInt(month) - 1);
|
|
const monthName = date.toLocaleString('en-US', { month: 'short' });
|
|
const currentYear = new Date().getFullYear();
|
|
|
|
if (parseInt(year) === currentYear) {
|
|
return monthName;
|
|
}
|
|
|
|
return `${monthName} ${year.slice(-2)}`;
|
|
}
|
|
|
|
function formatCurrency(value: number, currencyCode: string): string {
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: currencyCode,
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0,
|
|
}).format(value / 100);
|
|
}
|
|
|
|
function calculateTrend(
|
|
data: BalanceDataPoint[],
|
|
monthsBack: number,
|
|
): { percentage: number; previousValue: number; currentValue: number } | null {
|
|
if (data.length < 2) return null;
|
|
|
|
const currentIndex = data.length - 1;
|
|
const previousIndex = Math.max(0, data.length - 1 - monthsBack);
|
|
|
|
if (currentIndex === previousIndex) return null;
|
|
|
|
const currentValue = data[currentIndex].value;
|
|
const previousValue = data[previousIndex].value;
|
|
|
|
if (previousValue === 0) return null;
|
|
|
|
return {
|
|
percentage:
|
|
((currentValue - previousValue) / Math.abs(previousValue)) * 100,
|
|
previousValue,
|
|
currentValue,
|
|
};
|
|
}
|
|
|
|
export function AccountBalanceChart({
|
|
account,
|
|
loading: initialLoading,
|
|
refreshKey,
|
|
onBalanceClick,
|
|
}: AccountBalanceChartProps) {
|
|
const [balanceData, setBalanceData] = useState<AccountBalanceData | null>(
|
|
null,
|
|
);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
async function fetchBalanceData() {
|
|
setIsLoading(true);
|
|
try {
|
|
const now = new Date();
|
|
const to = format(now, 'yyyy-MM-dd');
|
|
const from = format(subMonths(now, 12), 'yyyy-MM-dd');
|
|
|
|
const params = new URLSearchParams({ from, to });
|
|
const response = await fetch(
|
|
`/api/dashboard/account/${account.id}/balance-evolution?${params.toString()}`,
|
|
);
|
|
const data = await response.json();
|
|
setBalanceData(data);
|
|
} catch (error) {
|
|
console.error('Failed to fetch balance data:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
fetchBalanceData();
|
|
}, [account.id, refreshKey]);
|
|
|
|
const { chartData, currentBalance, monthlyTrend, yearlyTrend } =
|
|
useMemo(() => {
|
|
if (!balanceData?.data?.length) {
|
|
return {
|
|
chartData: [],
|
|
currentBalance: 0,
|
|
monthlyTrend: null,
|
|
yearlyTrend: null,
|
|
};
|
|
}
|
|
|
|
const data = balanceData.data;
|
|
const current = data[data.length - 1]?.value ?? 0;
|
|
|
|
return {
|
|
chartData: data,
|
|
currentBalance: current,
|
|
monthlyTrend: calculateTrend(data, 1),
|
|
yearlyTrend: calculateTrend(data, data.length - 1),
|
|
};
|
|
}, [balanceData]);
|
|
|
|
// Convert data for useChartViews hook
|
|
const { data: hookData, accounts: hookAccounts } = useMemo(() => {
|
|
return convertSingleAccountData(
|
|
chartData,
|
|
account.id,
|
|
account.type,
|
|
account.currency_code,
|
|
);
|
|
}, [chartData, account.id, account.type, account.currency_code]);
|
|
|
|
const chartViews = useChartViews({
|
|
data: hookData,
|
|
accounts: hookAccounts,
|
|
initialView: 'stacked',
|
|
hasStackedView: true,
|
|
});
|
|
|
|
const chartConfig: ChartConfig = {
|
|
value: {
|
|
label: (
|
|
<EncryptedText
|
|
encryptedText={account.name}
|
|
iv={account.name_iv}
|
|
length={{ min: 5, max: 20 }}
|
|
/>
|
|
),
|
|
color: 'var(--color-chart-2)',
|
|
},
|
|
};
|
|
|
|
const valueFormatter = (value: number): string => {
|
|
return formatCurrency(value, account.currency_code);
|
|
};
|
|
|
|
if (initialLoading || isLoading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Balance evolution</CardTitle>
|
|
<CardDescription>
|
|
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-[300px] w-full animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (chartData.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Balance evolution</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
|
No balance data available
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card className="group">
|
|
<CardHeader>
|
|
<div className="flex flex-col items-start justify-between sm:flex-row">
|
|
<div className="flex flex-col gap-1 sm:gap-2">
|
|
<CardTitle>Balance evolution</CardTitle>
|
|
<button
|
|
type="button"
|
|
onClick={onBalanceClick}
|
|
className="-ml-3 cursor-pointer rounded-md px-2 py-1 text-left text-4xl font-semibold tabular-nums transition-colors hover:bg-muted"
|
|
>
|
|
<AmountDisplay
|
|
amountInCents={currentBalance}
|
|
currencyCode={account.currency_code}
|
|
minimumFractionDigits={0}
|
|
maximumFractionDigits={0}
|
|
/>
|
|
</button>
|
|
<CardDescription className="flex flex-col gap-1 text-sm">
|
|
<PercentageTrendIndicator
|
|
trend={monthlyTrend?.percentage ?? null}
|
|
label="this month"
|
|
previousAmount={monthlyTrend?.previousValue}
|
|
currentAmount={monthlyTrend?.currentValue}
|
|
currencyCode={account.currency_code}
|
|
/>
|
|
<PercentageTrendIndicator
|
|
trend={yearlyTrend?.percentage ?? null}
|
|
label="for the last 12 months"
|
|
previousAmount={yearlyTrend?.previousValue}
|
|
currentAmount={yearlyTrend?.currentValue}
|
|
currencyCode={account.currency_code}
|
|
/>
|
|
</CardDescription>
|
|
</div>
|
|
<ChartViewToggle
|
|
value={chartViews.currentView}
|
|
onValueChange={chartViews.setCurrentView}
|
|
availableViews={chartViews.availableViews}
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="relative">
|
|
{chartViews.currentView === 'stacked' && (
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="h-[300px] w-full"
|
|
>
|
|
<BarChart accessibilityLayer data={chartData.slice(1)}>
|
|
<XAxis
|
|
dataKey="month"
|
|
tickLine={false}
|
|
tickMargin={10}
|
|
axisLine={false}
|
|
tickFormatter={formatXAxisLabel}
|
|
/>
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
hideLabel
|
|
valueFormatter={valueFormatter}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar
|
|
dataKey="value"
|
|
fill="var(--color-chart-2)"
|
|
radius={[4, 4, 0, 0]}
|
|
/>
|
|
</BarChart>
|
|
</ChartContainer>
|
|
)}
|
|
{chartViews.currentView === 'mom' && (
|
|
<MoMChart
|
|
data={chartViews.deltaSeries}
|
|
currencyCode={account.currency_code}
|
|
xAxisFormatter={formatXAxisLabel}
|
|
className="h-[300px] w-full"
|
|
/>
|
|
)}
|
|
{chartViews.currentView === 'mom_percent' && (
|
|
<MoMPercentChart
|
|
data={chartViews.momPercentSeries}
|
|
xAxisFormatter={formatXAxisLabel}
|
|
className="h-[300px] w-full"
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|