Implement comprehensive Analytics Dashboard with charts and financial insights

Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-09-12 00:32:30 +00:00
committed by Elisiário Couto
parent 2b69b1e27b
commit 23aa8b08d4
10 changed files with 1092 additions and 8 deletions

View File

@@ -0,0 +1,127 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import type { Balance } from "../../types/api";
interface BalanceChartProps {
data: Balance[];
className?: string;
}
interface ChartDataPoint {
date: string;
balance: number;
account_id: string;
}
interface AggregatedDataPoint {
date: string;
[key: string]: string | number;
}
export default function BalanceChart({ data, className }: BalanceChartProps) {
// Process balance data for the chart
const chartData = data
.filter((balance) => balance.balance_type === "closingBooked")
.map((balance) => ({
date: new Date(balance.reference_date).toLocaleDateString(),
balance: balance.balance_amount,
account_id: balance.account_id,
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
// Group by account and aggregate
const accountBalances: { [key: string]: ChartDataPoint[] } = {};
chartData.forEach((item) => {
if (!accountBalances[item.account_id]) {
accountBalances[item.account_id] = [];
}
accountBalances[item.account_id].push(item);
});
// Create aggregated data points
const aggregatedData: { [key: string]: AggregatedDataPoint } = {};
Object.entries(accountBalances).forEach(([accountId, balances]) => {
balances.forEach((balance) => {
if (!aggregatedData[balance.date]) {
aggregatedData[balance.date] = { date: balance.date };
}
aggregatedData[balance.date][accountId] = balance.balance;
});
});
const finalData = Object.values(aggregatedData).sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
if (finalData.length === 0) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Balance Progress
</h3>
<div className="h-80 flex items-center justify-center text-gray-500">
No balance data available
</div>
</div>
);
}
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Balance Progress Over Time
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={finalData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}}
/>
<YAxis
tick={{ fontSize: 12 }}
tickFormatter={(value) => `${value.toLocaleString()}`}
/>
<Tooltip
formatter={(value: number) => [
`${value.toLocaleString()}`,
"Balance",
]}
labelFormatter={(label) => `Date: ${label}`}
/>
<Legend />
{Object.keys(accountBalances).map((accountId, index) => (
<Line
key={accountId}
type="monotone"
dataKey={accountId}
stroke={colors[index % colors.length]}
strokeWidth={2}
dot={{ r: 4 }}
name={`Account ${accountId.split('-')[1]}`}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -0,0 +1,170 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { useQuery } from "@tanstack/react-query";
import apiClient from "../../lib/api";
interface MonthlyTrendsProps {
className?: string;
}
interface MonthlyData {
month: string;
income: number;
expenses: number;
net: number;
}
interface TooltipProps {
active?: boolean;
payload?: Array<{
name: string;
value: number;
color: string;
}>;
label?: string;
}
export default function MonthlyTrends({ className }: MonthlyTrendsProps) {
// Get transactions for the last 12 months
const { data: transactions, isLoading } = useQuery({
queryKey: ["transactions", "monthly-trends"],
queryFn: async () => {
const response = await apiClient.getTransactions({
startDate: new Date(
Date.now() - 365 * 24 * 60 * 60 * 1000
).toISOString().split("T")[0],
endDate: new Date().toISOString().split("T")[0],
perPage: 1000,
});
return response.data;
},
});
// Process transactions into monthly data
const monthlyData: MonthlyData[] = [];
if (transactions) {
const monthlyMap: { [key: string]: MonthlyData } = {};
transactions.forEach((transaction) => {
const date = new Date(transaction.transaction_date);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
if (!monthlyMap[monthKey]) {
monthlyMap[monthKey] = {
month: date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short'
}),
income: 0,
expenses: 0,
net: 0,
};
}
if (transaction.transaction_value > 0) {
monthlyMap[monthKey].income += transaction.transaction_value;
} else {
monthlyMap[monthKey].expenses += Math.abs(transaction.transaction_value);
}
monthlyMap[monthKey].net = monthlyMap[monthKey].income - monthlyMap[monthKey].expenses;
});
// Convert to array and sort by date
monthlyData.push(
...Object.entries(monthlyMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([, data]) => data)
.slice(-12) // Last 12 months
);
}
if (isLoading) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Monthly Spending Trends
</h3>
<div className="h-80 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
);
}
if (monthlyData.length === 0) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Monthly Spending Trends
</h3>
<div className="h-80 flex items-center justify-center text-gray-500">
No transaction data available
</div>
</div>
);
}
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
if (active && payload && payload.length) {
return (
<div className="bg-white p-3 border rounded shadow-lg">
<p className="font-medium">{label}</p>
{payload.map((entry, index) => (
<p key={index} style={{ color: entry.color }}>
{entry.name}: {Math.abs(entry.value).toLocaleString()}
</p>
))}
</div>
);
}
return null;
};
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Monthly Spending Trends (Last 12 Months)
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="month"
tick={{ fontSize: 12 }}
angle={-45}
textAnchor="end"
height={60}
/>
<YAxis
tick={{ fontSize: 12 }}
tickFormatter={(value) => `${value.toLocaleString()}`}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="income" fill="#10B981" name="Income" />
<Bar dataKey="expenses" fill="#EF4444" name="Expenses" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-4 flex justify-center space-x-6 text-sm">
<div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded mr-2" />
<span>Income</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-red-500 rounded mr-2" />
<span>Expenses</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import type { LucideIcon } from "lucide-react";
import clsx from "clsx";
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
className?: string;
}
export default function StatCard({
title,
value,
subtitle,
icon: Icon,
trend,
className,
}: StatCardProps) {
return (
<div
className={clsx(
"bg-white rounded-lg shadow p-6 border border-gray-200",
className
)}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<Icon className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
{title}
</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">
{value}
</div>
{trend && (
<div
className={clsx(
"ml-2 flex items-baseline text-sm font-semibold",
trend.isPositive ? "text-green-600" : "text-red-600"
)}
>
{trend.isPositive ? "+" : ""}
{trend.value}%
</div>
)}
</dd>
{subtitle && (
<dd className="text-sm text-gray-600 mt-1">{subtitle}</dd>
)}
</dl>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
} from "recharts";
import type { Account } from "../../types/api";
interface TransactionDistributionProps {
accounts: Account[];
className?: string;
}
interface PieDataPoint {
name: string;
value: number;
color: string;
}
interface TooltipProps {
active?: boolean;
payload?: Array<{
payload: PieDataPoint;
}>;
}
export default function TransactionDistribution({
accounts,
className,
}: TransactionDistributionProps) {
// Create pie chart data from account balances
const pieData: PieDataPoint[] = accounts.map((account, index) => {
const closingBalance = account.balances.find(
(balance) => balance.balance_type === "closingBooked"
);
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
return {
name: account.name || `Account ${account.id.split('-')[1]}`,
value: closingBalance?.amount || 0,
color: colors[index % colors.length],
};
});
const totalBalance = pieData.reduce((sum, item) => sum + item.value, 0);
if (pieData.length === 0 || totalBalance === 0) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Account Distribution
</h3>
<div className="h-80 flex items-center justify-center text-gray-500">
No account data available
</div>
</div>
);
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const percentage = ((data.value / totalBalance) * 100).toFixed(1);
return (
<div className="bg-white p-3 border rounded shadow-lg">
<p className="font-medium">{data.name}</p>
<p className="text-blue-600">
Balance: {data.value.toLocaleString()}
</p>
<p className="text-gray-600">{percentage}% of total</p>
</div>
);
}
return null;
};
return (
<div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Account Balance Distribution
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
outerRadius={100}
innerRadius={40}
paddingAngle={2}
dataKey="value"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
formatter={(value, entry: { color?: string }) => (
<span style={{ color: entry.color }}>{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="mt-4 grid grid-cols-1 gap-2">
{pieData.map((item, index) => (
<div key={index} className="flex items-center justify-between text-sm">
<div className="flex items-center">
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-700">{item.name}</span>
</div>
<span className="font-medium">{item.value.toLocaleString()}</span>
</div>
))}
</div>
</div>
);
}