mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-28 20:49:11 +00:00
Compare commits
3 Commits
6f9cce4a36
...
a1cc445431
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1cc445431 | ||
|
|
2456520041 | ||
|
|
83cd136876 |
@@ -59,11 +59,11 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
|
||||
const chartData = data
|
||||
.filter((balance) => balance.balance_type === "closingBooked")
|
||||
.map((balance) => ({
|
||||
date: new Date(balance.reference_date).toLocaleDateString(),
|
||||
date: new Date(balance.reference_date).toLocaleDateString('en-GB'), // DD/MM/YYYY format
|
||||
balance: balance.balance_amount,
|
||||
account_id: balance.account_id,
|
||||
}))
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
.sort((a, b) => new Date(a.date.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).getTime());
|
||||
|
||||
// Group by account and aggregate
|
||||
const accountBalances: { [key: string]: ChartDataPoint[] } = {};
|
||||
@@ -86,7 +86,7 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
|
||||
});
|
||||
|
||||
const finalData = Object.values(aggregatedData).sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
(a, b) => new Date(a.date.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).getTime()
|
||||
);
|
||||
|
||||
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
||||
@@ -117,8 +117,10 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
// Convert DD/MM/YYYY back to a proper date for formatting
|
||||
const [day, month, year] = value.split('/');
|
||||
const date = new Date(year, month - 1, day);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import apiClient from "../../lib/api";
|
||||
|
||||
interface MonthlyTrendsProps {
|
||||
className?: string;
|
||||
days?: number;
|
||||
}
|
||||
|
||||
interface MonthlyData {
|
||||
@@ -31,13 +32,12 @@ interface TooltipProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function MonthlyTrends({ className }: MonthlyTrendsProps) {
|
||||
// Get transactions for the last 12 months using analytics endpoint
|
||||
export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsProps) {
|
||||
// Get transactions for the specified period using analytics endpoint
|
||||
const { data: transactions, isLoading } = useQuery({
|
||||
queryKey: ["transactions", "monthly-trends"],
|
||||
queryKey: ["transactions", "monthly-trends", days],
|
||||
queryFn: async () => {
|
||||
// Get last 365 days of transactions for monthly trends
|
||||
return await apiClient.getTransactionsForAnalytics(365);
|
||||
return await apiClient.getTransactionsForAnalytics(days);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
39
frontend/src/components/analytics/TimePeriodFilter.tsx
Normal file
39
frontend/src/components/analytics/TimePeriodFilter.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Calendar } from "lucide-react";
|
||||
import type { TimePeriod } from "../../lib/timePeriods";
|
||||
import { TIME_PERIODS } from "../../lib/timePeriods";
|
||||
|
||||
interface TimePeriodFilterProps {
|
||||
selectedPeriod: TimePeriod;
|
||||
onPeriodChange: (period: TimePeriod) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TimePeriodFilter({
|
||||
selectedPeriod,
|
||||
onPeriodChange,
|
||||
className = "",
|
||||
}: TimePeriodFilterProps) {
|
||||
return (
|
||||
<div className={`flex items-center gap-4 ${className}`}>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<Calendar size={20} />
|
||||
<span className="font-medium">Time Period:</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{TIME_PERIODS.map((period) => (
|
||||
<button
|
||||
key={period.value}
|
||||
onClick={() => onPeriodChange(period)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
selectedPeriod.value === period.value
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{period.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,15 +50,13 @@ export default function TransactionDistribution({
|
||||
|
||||
// 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 primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
|
||||
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
||||
|
||||
return {
|
||||
name: getAccountDisplayName(account),
|
||||
value: closingBalance?.amount || 0,
|
||||
value: primaryBalance,
|
||||
color: colors[index % colors.length],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -54,6 +54,18 @@ export const apiClient = {
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get historical balances for balance progression chart
|
||||
getHistoricalBalances: async (days?: number, accountId?: string): Promise<Balance[]> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (days) queryParams.append("days", days.toString());
|
||||
if (accountId) queryParams.append("account_id", accountId);
|
||||
|
||||
const response = await api.get<ApiResponse<Balance[]>>(
|
||||
`/balances/history?${queryParams.toString()}`
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get balances for specific account
|
||||
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
|
||||
const response = await api.get<ApiResponse<Balance[]>>(
|
||||
|
||||
19
frontend/src/lib/timePeriods.ts
Normal file
19
frontend/src/lib/timePeriods.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type TimePeriod = {
|
||||
label: string;
|
||||
days: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function getDaysFromYearStart(): number {
|
||||
const now = new Date();
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||
const diffTime = now.getTime() - yearStart.getTime();
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
export const TIME_PERIODS: TimePeriod[] = [
|
||||
{ label: "Last 30 days", days: 30, value: "30d" },
|
||||
{ label: "Last 6 months", days: 180, value: "6m" },
|
||||
{ label: "Year to Date", days: getDaysFromYearStart(), value: "ytd" },
|
||||
{ label: "Last 365 days", days: 365, value: "365d" },
|
||||
];
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
@@ -13,12 +14,20 @@ import StatCard from "../components/analytics/StatCard";
|
||||
import BalanceChart from "../components/analytics/BalanceChart";
|
||||
import TransactionDistribution from "../components/analytics/TransactionDistribution";
|
||||
import MonthlyTrends from "../components/analytics/MonthlyTrends";
|
||||
import TimePeriodFilter from "../components/analytics/TimePeriodFilter";
|
||||
import type { TimePeriod } from "../lib/timePeriods";
|
||||
import { TIME_PERIODS } from "../lib/timePeriods";
|
||||
|
||||
function AnalyticsDashboard() {
|
||||
// Default to Last 365 days
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(
|
||||
TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3]
|
||||
);
|
||||
|
||||
// Fetch analytics data
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ["transaction-stats"],
|
||||
queryFn: () => apiClient.getTransactionStats(365), // Last year
|
||||
queryKey: ["transaction-stats", selectedPeriod.days],
|
||||
queryFn: () => apiClient.getTransactionStats(selectedPeriod.days),
|
||||
});
|
||||
|
||||
const { data: accounts, isLoading: accountsLoading } = useQuery({
|
||||
@@ -27,8 +36,8 @@ function AnalyticsDashboard() {
|
||||
});
|
||||
|
||||
const { data: balances, isLoading: balancesLoading } = useQuery({
|
||||
queryKey: ["balances"],
|
||||
queryFn: () => apiClient.getBalances(),
|
||||
queryKey: ["historical-balances", selectedPeriod.days],
|
||||
queryFn: () => apiClient.getHistoricalBalances(selectedPeriod.days),
|
||||
});
|
||||
|
||||
const isLoading = statsLoading || accountsLoading || balancesLoading;
|
||||
@@ -53,10 +62,8 @@ function AnalyticsDashboard() {
|
||||
}
|
||||
|
||||
const totalBalance = accounts?.reduce((sum, account) => {
|
||||
const closingBalance = account.balances.find(
|
||||
(balance) => balance.balance_type === "closingBooked"
|
||||
);
|
||||
return sum + (closingBalance?.amount || 0);
|
||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
return sum + primaryBalance;
|
||||
}, 0) || 0;
|
||||
|
||||
return (
|
||||
@@ -68,6 +75,13 @@ function AnalyticsDashboard() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Time Period Filter */}
|
||||
<TimePeriodFilter
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
className="bg-white rounded-lg shadow p-4 border border-gray-200"
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
@@ -135,7 +149,7 @@ function AnalyticsDashboard() {
|
||||
|
||||
{/* Monthly Trends */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<MonthlyTrends />
|
||||
<MonthlyTrends days={selectedPeriod.days} />
|
||||
</div>
|
||||
|
||||
{/* Summary Section */}
|
||||
|
||||
@@ -520,3 +520,131 @@ def get_account(account_id: str):
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
raise e
|
||||
|
||||
|
||||
def get_historical_balances(account_id=None, days=365):
|
||||
"""Get historical balance progression based on transaction history"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Get current balance for each account/type to use as the final balance
|
||||
current_balances_query = """
|
||||
SELECT account_id, type, amount, currency
|
||||
FROM balances b1
|
||||
WHERE b1.timestamp = (
|
||||
SELECT MAX(b2.timestamp)
|
||||
FROM balances b2
|
||||
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
|
||||
)
|
||||
"""
|
||||
params = []
|
||||
|
||||
if account_id:
|
||||
current_balances_query += " AND b1.account_id = ?"
|
||||
params.append(account_id)
|
||||
|
||||
cursor.execute(current_balances_query, params)
|
||||
current_balances = {
|
||||
(row['account_id'], row['type']): {
|
||||
'amount': row['amount'],
|
||||
'currency': row['currency']
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
}
|
||||
|
||||
# Get transactions for the specified period, ordered by date descending
|
||||
from datetime import datetime, timedelta
|
||||
cutoff_date = (datetime.now() - timedelta(days=days)).isoformat()
|
||||
|
||||
transactions_query = """
|
||||
SELECT accountId, transactionDate, transactionValue
|
||||
FROM transactions
|
||||
WHERE transactionDate >= ?
|
||||
"""
|
||||
|
||||
if account_id:
|
||||
transactions_query += " AND accountId = ?"
|
||||
params = [cutoff_date, account_id]
|
||||
else:
|
||||
params = [cutoff_date]
|
||||
|
||||
transactions_query += " ORDER BY transactionDate DESC"
|
||||
|
||||
cursor.execute(transactions_query, params)
|
||||
transactions = cursor.fetchall()
|
||||
|
||||
# Calculate historical balances by working backwards from current balance
|
||||
historical_balances = []
|
||||
account_running_balances = {}
|
||||
|
||||
# Initialize running balances with current balances
|
||||
for (acc_id, balance_type), balance_info in current_balances.items():
|
||||
if acc_id not in account_running_balances:
|
||||
account_running_balances[acc_id] = {}
|
||||
account_running_balances[acc_id][balance_type] = balance_info['amount']
|
||||
|
||||
# Group transactions by date
|
||||
from collections import defaultdict
|
||||
transactions_by_date = defaultdict(list)
|
||||
|
||||
for txn in transactions:
|
||||
date_str = txn['transactionDate'][:10] # Extract just the date part
|
||||
transactions_by_date[date_str].append(txn)
|
||||
|
||||
# Generate historical balance points
|
||||
# Start from today and work backwards
|
||||
current_date = datetime.now().date()
|
||||
|
||||
for day_offset in range(0, days, 7): # Sample every 7 days for performance
|
||||
target_date = current_date - timedelta(days=day_offset)
|
||||
target_date_str = target_date.isoformat()
|
||||
|
||||
# For each account, create balance entries
|
||||
for acc_id in account_running_balances:
|
||||
for balance_type in ['closingBooked']: # Focus on closingBooked for the chart
|
||||
if balance_type in account_running_balances[acc_id]:
|
||||
balance_amount = account_running_balances[acc_id][balance_type]
|
||||
currency = current_balances.get((acc_id, balance_type), {}).get('currency', 'EUR')
|
||||
|
||||
historical_balances.append({
|
||||
'id': f"{acc_id}_{balance_type}_{target_date_str}",
|
||||
'account_id': acc_id,
|
||||
'balance_amount': balance_amount,
|
||||
'balance_type': balance_type,
|
||||
'currency': currency,
|
||||
'reference_date': target_date_str,
|
||||
'created_at': None,
|
||||
'updated_at': None
|
||||
})
|
||||
|
||||
# Subtract transactions that occurred on this date and later dates
|
||||
# to simulate going back in time
|
||||
for date_str in list(transactions_by_date.keys()):
|
||||
if date_str >= target_date_str:
|
||||
for txn in transactions_by_date[date_str]:
|
||||
acc_id = txn['accountId']
|
||||
amount = txn['transactionValue']
|
||||
|
||||
if acc_id in account_running_balances:
|
||||
for balance_type in account_running_balances[acc_id]:
|
||||
account_running_balances[acc_id][balance_type] -= amount
|
||||
|
||||
# Remove processed transactions to avoid double-processing
|
||||
del transactions_by_date[date_str]
|
||||
|
||||
conn.close()
|
||||
|
||||
# Sort by date for proper chronological order
|
||||
historical_balances.sort(key=lambda x: x['reference_date'])
|
||||
|
||||
return historical_balances
|
||||
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
raise e
|
||||
|
||||
@@ -215,6 +215,31 @@ async def get_all_balances() -> APIResponse:
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/balances/history", response_model=APIResponse)
|
||||
async def get_historical_balances(
|
||||
days: Optional[int] = Query(default=365, le=1095, ge=1, description="Number of days of history to retrieve"),
|
||||
account_id: Optional[str] = Query(default=None, description="Filter by specific account ID")
|
||||
) -> APIResponse:
|
||||
"""Get historical balance progression calculated from transaction history"""
|
||||
try:
|
||||
# Get historical balances from database
|
||||
historical_balances = await database_service.get_historical_balances_from_db(
|
||||
account_id=account_id, days=days
|
||||
)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=historical_balances,
|
||||
message=f"Retrieved {len(historical_balances)} historical balance points over {days} days",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get historical balances: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get historical balances: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
|
||||
async def get_account_transactions(
|
||||
account_id: str,
|
||||
|
||||
@@ -195,6 +195,22 @@ class DatabaseService:
|
||||
logger.error(f"Failed to get balances from database: {e}")
|
||||
return []
|
||||
|
||||
async def get_historical_balances_from_db(
|
||||
self, account_id: Optional[str] = None, days: int = 365
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get historical balance progression from SQLite database"""
|
||||
if not self.sqlite_enabled:
|
||||
logger.warning("SQLite database disabled, cannot read historical balances")
|
||||
return []
|
||||
|
||||
try:
|
||||
balances = sqlite_db.get_historical_balances(account_id=account_id, days=days)
|
||||
logger.debug(f"Retrieved {len(balances)} historical balance points from database")
|
||||
return balances
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get historical balances from database: {e}")
|
||||
return []
|
||||
|
||||
async def get_account_summary_from_db(
|
||||
self, account_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
Reference in New Issue
Block a user