mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-14 14:22:32 +00:00
Implement comprehensive Analytics Dashboard with charts and financial insights
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
This commit is contained in:
committed by
Elisiário Couto
parent
2b69b1e27b
commit
23aa8b08d4
127
frontend/src/components/analytics/BalanceChart.tsx
Normal file
127
frontend/src/components/analytics/BalanceChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
frontend/src/components/analytics/MonthlyTrends.tsx
Normal file
170
frontend/src/components/analytics/MonthlyTrends.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/analytics/StatCard.tsx
Normal file
64
frontend/src/components/analytics/StatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
frontend/src/components/analytics/TransactionDistribution.tsx
Normal file
126
frontend/src/components/analytics/TransactionDistribution.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
NotificationServicesResponse,
|
||||
HealthData,
|
||||
AccountUpdate,
|
||||
TransactionStats,
|
||||
} from "../types/api";
|
||||
|
||||
// Use VITE_API_URL for development, relative URLs for production
|
||||
@@ -142,6 +143,17 @@ export const apiClient = {
|
||||
const response = await api.get<ApiResponse<HealthData>>("/health");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Analytics endpoints
|
||||
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (days) queryParams.append("days", days.toString());
|
||||
|
||||
const response = await api.get<ApiResponse<TransactionStats>>(
|
||||
`/transactions/stats?${queryParams.toString()}`
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
|
||||
@@ -1,10 +1,181 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
Activity,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import apiClient from "../lib/api";
|
||||
import StatCard from "../components/analytics/StatCard";
|
||||
import BalanceChart from "../components/analytics/BalanceChart";
|
||||
import TransactionDistribution from "../components/analytics/TransactionDistribution";
|
||||
import MonthlyTrends from "../components/analytics/MonthlyTrends";
|
||||
|
||||
function AnalyticsDashboard() {
|
||||
// Fetch analytics data
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ["transaction-stats"],
|
||||
queryFn: () => apiClient.getTransactionStats(365), // Last year
|
||||
});
|
||||
|
||||
const { data: accounts, isLoading: accountsLoading } = useQuery({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: () => apiClient.getAccounts(),
|
||||
});
|
||||
|
||||
const { data: balances, isLoading: balancesLoading } = useQuery({
|
||||
queryKey: ["balances"],
|
||||
queryFn: () => apiClient.getBalances(),
|
||||
});
|
||||
|
||||
const isLoading = statsLoading || accountsLoading || balancesLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-48 mb-6"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="h-96 bg-gray-200 rounded"></div>
|
||||
<div className="h-96 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalBalance = accounts?.reduce((sum, account) => {
|
||||
const closingBalance = account.balances.find(
|
||||
(balance) => balance.balance_type === "closingBooked"
|
||||
);
|
||||
return sum + (closingBalance?.amount || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Analytics Dashboard</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Overview of your financial data and spending patterns
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Total Balance"
|
||||
value={`€${totalBalance.toLocaleString()}`}
|
||||
subtitle={`Across ${accounts?.length || 0} accounts`}
|
||||
icon={DollarSign}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Transactions"
|
||||
value={stats?.total_transactions || 0}
|
||||
subtitle={`Last ${stats?.period_days || 0} days`}
|
||||
icon={Activity}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Income"
|
||||
value={`€${(stats?.total_income || 0).toLocaleString()}`}
|
||||
subtitle="Inflows this period"
|
||||
icon={TrendingUp}
|
||||
className="border-green-200"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Expenses"
|
||||
value={`€${(stats?.total_expenses || 0).toLocaleString()}`}
|
||||
subtitle="Outflows this period"
|
||||
icon={TrendingDown}
|
||||
className="border-red-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<StatCard
|
||||
title="Net Change"
|
||||
value={`€${(stats?.net_change || 0).toLocaleString()}`}
|
||||
subtitle="Income minus expenses"
|
||||
icon={CreditCard}
|
||||
className={
|
||||
(stats?.net_change || 0) >= 0 ? "border-green-200" : "border-red-200"
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="Average Transaction"
|
||||
value={`€${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
|
||||
subtitle="Per transaction"
|
||||
icon={Activity}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Accounts"
|
||||
value={stats?.accounts_included || 0}
|
||||
subtitle="With recent activity"
|
||||
icon={Users}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<BalanceChart data={balances || []} />
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<TransactionDistribution accounts={accounts || []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly Trends */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<MonthlyTrends />
|
||||
</div>
|
||||
|
||||
{/* Summary Section */}
|
||||
{stats && (
|
||||
<div className="bg-blue-50 rounded-lg p-6 border border-blue-200">
|
||||
<h3 className="text-lg font-medium text-blue-900 mb-4">
|
||||
Period Summary ({stats.period_days} days)
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-blue-700 font-medium">Booked Transactions</p>
|
||||
<p className="text-blue-900">{stats.booked_transactions}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-blue-700 font-medium">Pending Transactions</p>
|
||||
<p className="text-blue-900">{stats.pending_transactions}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-blue-700 font-medium">Transaction Ratio</p>
|
||||
<p className="text-blue-900">
|
||||
{stats.total_transactions > 0
|
||||
? `${Math.round(
|
||||
(stats.booked_transactions / stats.total_transactions) * 100
|
||||
)}% booked`
|
||||
: "No transactions"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-blue-700 font-medium">Spend Rate</p>
|
||||
<p className="text-blue-900">
|
||||
€{((stats.total_expenses || 0) / stats.period_days).toFixed(2)}/day
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/analytics")({
|
||||
component: () => (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Analytics</h3>
|
||||
<p className="text-gray-600">Analytics dashboard coming soon...</p>
|
||||
</div>
|
||||
),
|
||||
component: AnalyticsDashboard,
|
||||
});
|
||||
|
||||
@@ -188,3 +188,16 @@ export interface HealthData {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Analytics data types
|
||||
export interface TransactionStats {
|
||||
period_days: number;
|
||||
total_transactions: number;
|
||||
booked_transactions: number;
|
||||
pending_transactions: number;
|
||||
total_income: number;
|
||||
total_expenses: number;
|
||||
net_change: number;
|
||||
average_transaction: number;
|
||||
accounts_included: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user