mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 09:28:47 +02:00
Some checks failed
Deploy to VPS / deploy (push) Failing after 7s
Backend: FastAPI + PostgreSQL with models for accounts, transactions, and categories. Auto-categorization from merchant patterns, token auth, CRUD endpoints, and seed data for 16 categories and 4 bank accounts. Frontend: Login, Dashboard (account balances + recent charges), Transactions (full CRUD table with search/filter), Cash & Transfers view. Dark theme with emerald/cyan accents, responsive layout. Infrastructure: Updated docker-compose for backend + db services, nginx proxy config for API routing, deploy workflow with secrets. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
198 lines
7.1 KiB
TypeScript
198 lines
7.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import {
|
|
ArrowRight,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
RefreshCw,
|
|
CreditCard,
|
|
} from 'lucide-react';
|
|
|
|
import api, { type Account, type Transaction } from '../api';
|
|
|
|
function formatAmount(amount: number, currency: string) {
|
|
const abs = Math.abs(amount);
|
|
if (currency === 'USD') {
|
|
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
}
|
|
return `₡${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
function formatDate(dateStr: string) {
|
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
|
const [recent, setRecent] = useState<Transaction[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [accRes, txRes] = await Promise.all([
|
|
api.get('/accounts/'),
|
|
api.get('/transactions/recent?limit=5'),
|
|
]);
|
|
setAccounts(accRes.data);
|
|
setRecent(txRes.data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
const totalCRC = accounts
|
|
.filter((a) => a.currency === 'CRC')
|
|
.reduce((s, a) => s + a.balance, 0);
|
|
const totalUSD = accounts
|
|
.filter((a) => a.currency === 'USD')
|
|
.reduce((s, a) => s + a.balance, 0);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
|
<p className="text-sm text-slate-500 mt-1">Financial overview</p>
|
|
</div>
|
|
<button
|
|
onClick={fetchData}
|
|
className="p-2 rounded-lg text-slate-500 hover:text-white hover:bg-slate-800/50 transition-colors"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Account balances */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{accounts.map((account, i) => (
|
|
<div
|
|
key={account.id}
|
|
className="relative group animate-fade-in"
|
|
>
|
|
<div className="absolute -inset-[1px] rounded-xl bg-gradient-to-br from-emerald-500/20 to-cyan-500/20 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
<div className="relative bg-slate-900/60 border border-slate-800/60 rounded-xl p-5">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
{account.label}
|
|
</span>
|
|
<span className="text-[10px] font-mono text-slate-600 bg-slate-800/60 px-2 py-0.5 rounded">
|
|
{account.bank}
|
|
</span>
|
|
</div>
|
|
<p className="text-2xl font-bold font-mono tracking-tight">
|
|
{formatAmount(account.balance, account.currency)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Totals */}
|
|
{accounts.length > 0 && (
|
|
<>
|
|
<div
|
|
className="bg-gradient-to-br from-emerald-500/10 to-cyan-500/5 border border-emerald-500/20 rounded-xl p-5"
|
|
>
|
|
<span className="text-xs font-medium text-emerald-400/80 uppercase tracking-wider">
|
|
Total CRC
|
|
</span>
|
|
<p className="text-2xl font-bold font-mono tracking-tight text-emerald-400 mt-3">
|
|
{formatAmount(totalCRC, 'CRC')}
|
|
</p>
|
|
</div>
|
|
<div
|
|
className="bg-gradient-to-br from-cyan-500/10 to-emerald-500/5 border border-cyan-500/20 rounded-xl p-5 animate-fade-in"
|
|
>
|
|
<span className="text-xs font-medium text-cyan-400/80 uppercase tracking-wider">
|
|
Total USD
|
|
</span>
|
|
<p className="text-2xl font-bold font-mono tracking-tight text-cyan-400 mt-3">
|
|
{formatAmount(totalUSD, 'USD')}
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recent transactions */}
|
|
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl">
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800/40">
|
|
<div className="flex items-center gap-2">
|
|
<CreditCard className="w-4 h-4 text-slate-500" />
|
|
<h2 className="font-semibold text-sm">Recent Charges</h2>
|
|
</div>
|
|
<Link
|
|
to="/transactions"
|
|
className="flex items-center gap-1 text-xs font-medium text-emerald-400 hover:text-emerald-300 transition-colors"
|
|
>
|
|
View all
|
|
<ArrowRight className="w-3 h-3" />
|
|
</Link>
|
|
</div>
|
|
|
|
{recent.length === 0 && !loading ? (
|
|
<div className="px-5 py-12 text-center text-slate-600 text-sm">
|
|
No transactions yet. Add your first one!
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-slate-800/40">
|
|
{recent.map((tx, i) => (
|
|
<div
|
|
key={tx.id}
|
|
className="flex items-center justify-between px-5 py-3.5 hover:bg-slate-800/20 transition-colors animate-fade-in"
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div
|
|
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
|
tx.transaction_type === 'DEVOLUCION'
|
|
? 'bg-emerald-500/10 text-emerald-400'
|
|
: 'bg-red-500/10 text-red-400'
|
|
}`}
|
|
>
|
|
{tx.transaction_type === 'DEVOLUCION' ? (
|
|
<TrendingUp className="w-4 h-4" />
|
|
) : (
|
|
<TrendingDown className="w-4 h-4" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
|
<p className="text-xs text-slate-500">
|
|
{formatDate(tx.date)}
|
|
{tx.category && (
|
|
<span className="ml-2 text-slate-600">
|
|
{tx.category.name}
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<span
|
|
className={`font-mono text-sm font-medium flex-shrink-0 ml-4 ${
|
|
tx.transaction_type === 'DEVOLUCION'
|
|
? 'text-emerald-400'
|
|
: 'text-white'
|
|
}`}
|
|
>
|
|
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
|
{formatAmount(tx.amount, tx.currency)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|