mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 19:32:25 +00:00
- Install and configure TanStack Router for type-safe routing - Create route structure with __root.tsx layout and individual route files - Implement mobile-responsive sidebar with collapse functionality - Add clickable logo in sidebar that navigates to overview page - Extract Header and Sidebar components from Dashboard for reusability - Configure Vite with TanStack Router plugin for development - Update main.tsx to use RouterProvider instead of direct App rendering - Maintain existing TanStack Query integration seamlessly - Add proper TypeScript types for all route components - Implement responsive design with mobile overlay and hamburger menu This replaces the tab-based navigation with URL-based routing while maintaining the same user experience and adding powerful features like: - Bookmarkable URLs (/transactions, /analytics, /notifications) - Browser back/forward button support - Direct linking capabilities - Mobile-responsive sidebar with smooth animations - Type-safe navigation with auto-completion
309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
CreditCard,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
Building2,
|
|
RefreshCw,
|
|
AlertCircle,
|
|
Edit2,
|
|
Check,
|
|
X,
|
|
} from "lucide-react";
|
|
import { apiClient } from "../lib/api";
|
|
import { formatCurrency, formatDate } from "../lib/utils";
|
|
import LoadingSpinner from "./LoadingSpinner";
|
|
import type { Account, Balance } from "../types/api";
|
|
|
|
export default function AccountsOverview() {
|
|
const {
|
|
data: accounts,
|
|
isLoading: accountsLoading,
|
|
error: accountsError,
|
|
refetch: refetchAccounts,
|
|
} = useQuery<Account[]>({
|
|
queryKey: ["accounts"],
|
|
queryFn: apiClient.getAccounts,
|
|
});
|
|
|
|
const { data: balances } = useQuery<Balance[]>({
|
|
queryKey: ["balances"],
|
|
queryFn: () => apiClient.getBalances(),
|
|
});
|
|
|
|
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
|
const [editingName, setEditingName] = useState("");
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
const updateAccountMutation = useMutation({
|
|
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
|
apiClient.updateAccount(id, { name }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
|
setEditingAccountId(null);
|
|
setEditingName("");
|
|
},
|
|
onError: (error) => {
|
|
console.error("Failed to update account:", error);
|
|
},
|
|
});
|
|
|
|
const handleEditStart = (account: Account) => {
|
|
setEditingAccountId(account.id);
|
|
setEditingName(account.name || "");
|
|
};
|
|
|
|
const handleEditSave = () => {
|
|
if (editingAccountId && editingName.trim()) {
|
|
updateAccountMutation.mutate({
|
|
id: editingAccountId,
|
|
name: editingName.trim(),
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleEditCancel = () => {
|
|
setEditingAccountId(null);
|
|
setEditingName("");
|
|
};
|
|
|
|
if (accountsLoading) {
|
|
return (
|
|
<div className="bg-white rounded-lg shadow">
|
|
<LoadingSpinner message="Loading accounts..." />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (accountsError) {
|
|
return (
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-center justify-center text-center">
|
|
<div>
|
|
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
Failed to load accounts
|
|
</h3>
|
|
<p className="text-gray-600 mb-4">
|
|
Unable to connect to the Leggen API. Please check your
|
|
configuration and ensure the API server is running.
|
|
</p>
|
|
<button
|
|
onClick={() => refetchAccounts()}
|
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
|
>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const totalBalance =
|
|
accounts?.reduce((sum, account) => {
|
|
// Get the first available balance from the balances array
|
|
const primaryBalance = account.balances?.[0]?.amount || 0;
|
|
return sum + primaryBalance;
|
|
}, 0) || 0;
|
|
const totalAccounts = accounts?.length || 0;
|
|
const uniqueBanks = new Set(accounts?.map((acc) => acc.institution_id) || [])
|
|
.size;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-600">Total Balance</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{formatCurrency(totalBalance)}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-green-100 rounded-full">
|
|
<TrendingUp className="h-6 w-6 text-green-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-600">
|
|
Total Accounts
|
|
</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{totalAccounts}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-blue-100 rounded-full">
|
|
<CreditCard className="h-6 w-6 text-blue-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-600">
|
|
Connected Banks
|
|
</p>
|
|
<p className="text-2xl font-bold text-gray-900">{uniqueBanks}</p>
|
|
</div>
|
|
<div className="p-3 bg-purple-100 rounded-full">
|
|
<Building2 className="h-6 w-6 text-purple-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Accounts List */}
|
|
<div className="bg-white rounded-lg shadow">
|
|
<div className="px-6 py-4 border-b border-gray-200">
|
|
<h3 className="text-lg font-medium text-gray-900">Bank Accounts</h3>
|
|
<p className="text-sm text-gray-600">
|
|
Manage your connected bank accounts
|
|
</p>
|
|
</div>
|
|
|
|
{!accounts || accounts.length === 0 ? (
|
|
<div className="p-6 text-center">
|
|
<CreditCard className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
No accounts found
|
|
</h3>
|
|
<p className="text-gray-600">
|
|
Connect your first bank account to get started with Leggen.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-200">
|
|
{accounts.map((account) => {
|
|
// Get balance from account's balances array or fallback to balances query
|
|
const accountBalance = account.balances?.[0];
|
|
const fallbackBalance = balances?.find(
|
|
(b) => b.account_id === account.id,
|
|
);
|
|
const balance =
|
|
accountBalance?.amount || fallbackBalance?.balance_amount || 0;
|
|
const currency =
|
|
accountBalance?.currency ||
|
|
fallbackBalance?.currency ||
|
|
account.currency ||
|
|
"EUR";
|
|
const isPositive = balance >= 0;
|
|
|
|
return (
|
|
<div
|
|
key={account.id}
|
|
className="p-6 hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="p-3 bg-gray-100 rounded-full">
|
|
<Building2 className="h-6 w-6 text-gray-600" />
|
|
</div>
|
|
<div className="flex-1">
|
|
{editingAccountId === account.id ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center space-x-2">
|
|
<input
|
|
type="text"
|
|
value={editingName}
|
|
onChange={(e) => setEditingName(e.target.value)}
|
|
className="flex-1 px-3 py-1 text-lg font-medium border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="Account name"
|
|
name="search"
|
|
autoComplete="off"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleEditSave();
|
|
if (e.key === "Escape") handleEditCancel();
|
|
}}
|
|
autoFocus
|
|
/>
|
|
<button
|
|
onClick={handleEditSave}
|
|
disabled={
|
|
!editingName.trim() ||
|
|
updateAccountMutation.isPending
|
|
}
|
|
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="Save changes"
|
|
>
|
|
<Check className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={handleEditCancel}
|
|
className="p-1 text-gray-600 hover:text-gray-700"
|
|
title="Cancel editing"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-gray-600">
|
|
{account.institution_id} • {account.status}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="flex items-center space-x-2">
|
|
<h4 className="text-lg font-medium text-gray-900">
|
|
{account.name || "Unnamed Account"}
|
|
</h4>
|
|
<button
|
|
onClick={() => handleEditStart(account)}
|
|
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
|
title="Edit account name"
|
|
>
|
|
<Edit2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-gray-600">
|
|
{account.institution_id} • {account.status}
|
|
</p>
|
|
{account.iban && (
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
IBAN: {account.iban}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-right">
|
|
<div className="flex items-center space-x-2">
|
|
{isPositive ? (
|
|
<TrendingUp className="h-4 w-4 text-green-500" />
|
|
) : (
|
|
<TrendingDown className="h-4 w-4 text-red-500" />
|
|
)}
|
|
<p
|
|
className={`text-lg font-semibold ${
|
|
isPositive ? "text-green-600" : "text-red-600"
|
|
}`}
|
|
>
|
|
{formatCurrency(balance, currency)}
|
|
</p>
|
|
</div>
|
|
<p className="text-sm text-gray-500">
|
|
Updated{" "}
|
|
{formatDate(account.last_accessed || account.created)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|