Add budget module: FastAPI backend + React frontend
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>
This commit is contained in:
Carlos Escalante
2026-03-21 11:33:38 -06:00
parent cfd2eba849
commit 13161b8e49
34 changed files with 1855 additions and 112 deletions

View File

@@ -0,0 +1,197 @@
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>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Wallet, ArrowRight, AlertCircle } from 'lucide-react';
import { login } from '../api';
import { useAuth } from '../AuthContext';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { setAuthenticated } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await login(username, password);
setAuthenticated(true);
navigate('/');
} catch {
setError('Invalid credentials');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center px-4">
<div className="w-full max-w-sm animate-fade-in">
<div className="flex items-center justify-center gap-2.5 mb-8">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
<Wallet className="w-6 h-6 text-slate-950" strokeWidth={2.5} />
</div>
<span className="text-2xl font-bold tracking-tight text-white">
Wealthy<span className="text-emerald-400">Smart</span>
</span>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-4 py-3 text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors"
placeholder="Enter username"
autoFocus
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-4 py-3 text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors"
placeholder="Enter password"
/>
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-emerald-500 hover:bg-emerald-400 disabled:opacity-50 text-slate-950 font-semibold px-6 py-3 rounded-lg transition-colors"
>
{loading ? 'Signing in...' : 'Sign in'}
{!loading && <ArrowRight className="w-4 h-4" />}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,235 @@
import { useEffect, useState, useCallback } from 'react';
import {
Plus,
Search,
Pencil,
Trash2,
TrendingUp,
TrendingDown,
ChevronDown,
} from 'lucide-react';
import api, { type Transaction, type Category } from '../api';
import TransactionModal from '../components/TransactionModal';
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 })}`;
}
export default function Transactions() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
const fetchTransactions = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { source: 'CREDIT_CARD', limit: '200' };
if (search) params.search = search;
if (categoryFilter) params.category_id = categoryFilter;
const { data } = await api.get('/transactions/', { params });
setTransactions(data);
} finally {
setLoading(false);
}
}, [search, categoryFilter]);
useEffect(() => {
api.get('/categories/').then((r) => setCategories(r.data));
}, []);
useEffect(() => {
const timer = setTimeout(fetchTransactions, 300);
return () => clearTimeout(timer);
}, [fetchTransactions]);
const handleDelete = async (id: number) => {
if (!confirm('Delete this transaction?')) return;
await api.delete(`/transactions/${id}`);
fetchTransactions();
};
const total = transactions.reduce((sum, tx) => {
const signed = tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount;
return sum + signed;
}, 0);
return (
<div className="space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Credit Card Transactions</h1>
<p className="text-sm text-slate-500 mt-1">
{transactions.length} transactions &middot; Total:{' '}
<span className="font-mono text-white">{formatAmount(total, 'CRC')}</span>
</p>
</div>
<button
onClick={() => {
setEditing(null);
setModalOpen(true);
}}
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
>
<Plus className="w-4 h-4" />
Add Transaction
</button>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-slate-900/60 border border-slate-800/60 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 transition-colors"
placeholder="Search merchants..."
/>
</div>
<div className="relative">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="appearance-none bg-slate-900/60 border border-slate-800/60 rounded-lg pl-4 pr-10 py-2.5 text-sm text-white focus:outline-none focus:border-emerald-500/50 transition-colors"
>
<option value="">All Categories</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
</div>
</div>
{/* Table */}
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-800/40">
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider">
Date
</th>
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider">
Merchant
</th>
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider hidden md:table-cell">
Category
</th>
<th className="text-right px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider">
Amount
</th>
<th className="text-right px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider w-20">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{transactions.map((tx) => (
<tr
key={tx.id}
className="hover:bg-slate-800/20 transition-colors group"
>
<td className="px-5 py-3 whitespace-nowrap">
<span className="font-mono text-slate-400 text-xs">
{new Date(tx.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<div
className={`w-6 h-6 rounded 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-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
</div>
<span className="truncate max-w-[200px] sm:max-w-none">{tx.merchant}</span>
</div>
</td>
<td className="px-5 py-3 hidden md:table-cell">
{tx.category ? (
<span className="text-xs bg-slate-800/60 text-slate-400 px-2 py-1 rounded">
{tx.category.name}
</span>
) : (
<span className="text-xs text-slate-600"></span>
)}
</td>
<td className="px-5 py-3 text-right whitespace-nowrap">
<span
className={`font-mono font-medium ${
tx.transaction_type === 'DEVOLUCION'
? 'text-emerald-400'
: 'text-white'
}`}
>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
{formatAmount(tx.amount, tx.currency)}
</span>
</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {
setEditing(tx);
setModalOpen(true);
}}
className="p-1.5 rounded hover:bg-slate-700/50 text-slate-500 hover:text-white transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(tx.id)}
className="p-1.5 rounded hover:bg-red-500/10 text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{transactions.length === 0 && !loading && (
<div className="px-5 py-16 text-center text-slate-600 text-sm">
No transactions found
</div>
)}
</div>
{modalOpen && (
<TransactionModal
transaction={editing}
source="CREDIT_CARD"
onClose={() => setModalOpen(false)}
onSaved={fetchTransactions}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { useEffect, useState, useCallback } from 'react';
import { Plus, Search, Pencil, Trash2, ArrowLeftRight } from 'lucide-react';
import api, { type Transaction } from '../api';
import TransactionModal from '../components/TransactionModal';
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 })}`;
}
type SourceTab = 'CASH' | 'TRANSFER';
export default function Transfers() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [search, setSearch] = useState('');
const [sourceTab, setSourceTab] = useState<SourceTab>('CASH');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
const fetchTransactions = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { source: sourceTab, limit: '200' };
if (search) params.search = search;
const { data } = await api.get('/transactions/', { params });
setTransactions(data);
} finally {
setLoading(false);
}
}, [search, sourceTab]);
useEffect(() => {
const timer = setTimeout(fetchTransactions, 300);
return () => clearTimeout(timer);
}, [fetchTransactions]);
const handleDelete = async (id: number) => {
if (!confirm('Delete this transaction?')) return;
await api.delete(`/transactions/${id}`);
fetchTransactions();
};
return (
<div className="space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Cash & Transfers</h1>
<p className="text-sm text-slate-500 mt-1">
Track non-credit-card expenses
</p>
</div>
<button
onClick={() => {
setEditing(null);
setModalOpen(true);
}}
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
>
<Plus className="w-4 h-4" />
Add {sourceTab === 'CASH' ? 'Cash Expense' : 'Transfer'}
</button>
</div>
{/* Source tabs */}
<div className="flex gap-1 bg-slate-900/40 border border-slate-800/60 rounded-lg p-1 w-fit">
{(['CASH', 'TRANSFER'] as const).map((tab) => (
<button
key={tab}
onClick={() => setSourceTab(tab)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
sourceTab === tab
? 'bg-emerald-500/10 text-emerald-400'
: 'text-slate-500 hover:text-white'
}`}
>
{tab === 'CASH' ? 'Cash' : 'Transfers'}
</button>
))}
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-slate-900/60 border border-slate-800/60 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 transition-colors"
placeholder="Search..."
/>
</div>
{/* List */}
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl divide-y divide-slate-800/30">
{transactions.length === 0 && !loading ? (
<div className="px-5 py-16 text-center text-slate-600 text-sm">
<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-slate-700" />
No {sourceTab.toLowerCase()} transactions yet
</div>
) : (
<>
{transactions.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between px-5 py-4 hover:bg-slate-800/20 transition-colors group"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
<p className="text-xs text-slate-500 mt-0.5">
{new Date(tx.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
{tx.category && (
<span className="ml-2 bg-slate-800/60 text-slate-400 px-2 py-0.5 rounded">
{tx.category.name}
</span>
)}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0 ml-4">
<span className="font-mono text-sm font-medium text-white">
{formatAmount(tx.amount, tx.currency)}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {
setEditing(tx);
setModalOpen(true);
}}
className="p-1.5 rounded hover:bg-slate-700/50 text-slate-500 hover:text-white transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(tx.id)}
className="p-1.5 rounded hover:bg-red-500/10 text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
))}
</>
)}
</div>
{modalOpen && (
<TransactionModal
transaction={editing}
source={sourceTab}
onClose={() => setModalOpen(false)}
onSaved={fetchTransactions}
/>
)}
</div>
);
}