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

@@ -1,113 +1,46 @@
import {
TrendingUp,
Shield,
Smartphone,
BarChart3,
Wallet,
ArrowRight,
} from 'lucide-react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './AuthContext';
import Layout from './components/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Transactions from './pages/Transactions';
import Transfers from './pages/Transfers';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
}
function AppRoutes() {
const { isAuthenticated } = useAuth();
function App() {
return (
<div className="min-h-screen bg-slate-950 text-white">
{/* Nav */}
<nav className="border-b border-slate-800/60 backdrop-blur-sm sticky top-0 z-50 bg-slate-950/80">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
<Wallet className="w-5 h-5 text-slate-950" strokeWidth={2.5} />
</div>
<span className="text-xl font-bold tracking-tight">
Wealthy<span className="text-emerald-400">Smart</span>
</span>
</div>
<span className="text-xs font-medium text-slate-500 border border-slate-800 rounded-full px-3 py-1">
Coming Soon
</span>
</div>
</nav>
{/* Hero */}
<section className="max-w-6xl mx-auto px-6 pt-24 pb-20">
<div className="max-w-2xl">
<div className="inline-flex items-center gap-2 text-emerald-400 text-sm font-medium mb-6 bg-emerald-400/10 rounded-full px-4 py-1.5">
<TrendingUp className="w-4 h-4" />
Personal Finance, Simplified
</div>
<h1 className="text-5xl sm:text-6xl font-extrabold leading-[1.1] tracking-tight mb-6">
Take control of your{' '}
<span className="bg-gradient-to-r from-emerald-400 to-cyan-400 bg-clip-text text-transparent">
financial future
</span>
</h1>
<p className="text-lg text-slate-400 leading-relaxed mb-4 max-w-xl">
Budget tracking, investment management, and financial insights all
in one place. Built to replace spreadsheets with something smarter.
</p>
<p className="text-base text-slate-500 leading-relaxed mb-10 max-w-xl">
Track every dollar with precision, monitor your investments in real
time, and uncover meaningful insights about your financial habits
without juggling multiple tools. This platform centralizes your
entire financial picture, turning scattered data into clear,
actionable intelligence. Instead of manually updating spreadsheets
and second-guessing your numbers, you get automated tracking,
intelligent categorization, and forward-looking analysis that helps
you make better decisions faster. It's not just a replacement for
spreadsheets—it's a system designed to actively improve how you
manage, grow, and understand your money.
</p>
<div className="flex items-center gap-3">
<div className="inline-flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold px-6 py-3 rounded-lg transition-colors">
Get Started
<ArrowRight className="w-4 h-4" />
</div>
</div>
</div>
</section>
{/* Features */}
<section className="max-w-6xl mx-auto px-6 pb-24">
<div className="grid md:grid-cols-3 gap-6">
{[
{
icon: BarChart3,
title: 'Budget Tracking',
desc: 'Track income, expenses, and savings across multiple accounts and currencies with real-time balance updates.',
},
{
icon: Shield,
title: 'Investments & Pensions',
desc: 'Monitor your portfolio, pension funds, and long-term savings all in a single dashboard.',
},
{
icon: Smartphone,
title: 'Mobile First',
desc: 'Check and edit your finances on the go. Designed to work seamlessly on any device.',
},
].map(({ icon: Icon, title, desc }) => (
<div
key={title}
className="group border border-slate-800/60 rounded-xl p-6 hover:border-emerald-500/30 transition-colors bg-slate-900/40"
>
<div className="w-10 h-10 rounded-lg bg-emerald-400/10 flex items-center justify-center mb-4 group-hover:bg-emerald-400/20 transition-colors">
<Icon className="w-5 h-5 text-emerald-400" />
</div>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-sm text-slate-400 leading-relaxed">{desc}</p>
</div>
))}
</div>
</section>
{/* Footer */}
<footer className="border-t border-slate-800/60 py-8">
<div className="max-w-6xl mx-auto px-6 flex items-center justify-between text-sm text-slate-500">
<span>&copy; {new Date().getFullYear()} WealthySmart</span>
<span>wealth.cescalante.dev</span>
</div>
</footer>
</div>
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
/>
<Route
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route path="/" element={<Dashboard />} />
<Route path="/transactions" element={<Transactions />} />
<Route path="/transfers" element={<Transfers />} />
</Route>
</Routes>
);
}
export default App;
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,36 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
interface AuthCtx {
isAuthenticated: boolean;
logout: () => void;
setAuthenticated: (v: boolean) => void;
}
const AuthContext = createContext<AuthCtx>({
isAuthenticated: false,
logout: () => {},
setAuthenticated: () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setAuthenticated] = useState(!!localStorage.getItem('token'));
useEffect(() => {
const check = () => setAuthenticated(!!localStorage.getItem('token'));
window.addEventListener('storage', check);
return () => window.removeEventListener('storage', check);
}, []);
const logout = () => {
localStorage.removeItem('token');
setAuthenticated(false);
};
return (
<AuthContext.Provider value={{ isAuthenticated, logout, setAuthenticated }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);

69
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,69 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(err);
},
);
export default api;
export async function login(username: string, password: string) {
const form = new URLSearchParams();
form.append('username', username);
form.append('password', password);
const { data } = await api.post('/auth/login', form);
localStorage.setItem('token', data.access_token);
return data;
}
export interface Account {
id: number;
bank: string;
currency: string;
label: string;
balance: number;
updated_at: string;
}
export interface Category {
id: number;
name: string;
icon: string;
auto_match_patterns: string | null;
}
export interface Transaction {
id: number;
amount: number;
currency: string;
merchant: string;
city: string | null;
date: string;
card_type: string | null;
card_last4: string | null;
authorization_code: string | null;
reference: string | null;
transaction_type: string;
source: string;
bank: string;
notes: string | null;
category_id: number | null;
category: Category | null;
created_at: string;
}

View File

@@ -0,0 +1,118 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
CreditCard,
ArrowLeftRight,
LogOut,
Wallet,
Menu,
X,
} from 'lucide-react';
import { useState } from 'react';
import { useAuth } from '../AuthContext';
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/transactions', icon: CreditCard, label: 'Transactions' },
{ to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' },
];
export default function Layout() {
const { logout } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div className="min-h-screen bg-slate-950 text-white">
{/* Top bar */}
<header className="border-b border-slate-800/60 backdrop-blur-sm sticky top-0 z-50 bg-slate-950/90">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
<Wallet className="w-4 h-4 text-slate-950" strokeWidth={2.5} />
</div>
<span className="text-lg font-bold tracking-tight hidden sm:inline">
Wealthy<span className="text-emerald-400">Smart</span>
</span>
</div>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-emerald-500/10 text-emerald-400'
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
}`
}
>
<Icon className="w-4 h-4" />
{label}
</NavLink>
))}
</nav>
<div className="flex items-center gap-2">
<button
onClick={handleLogout}
className="hidden md:flex items-center gap-2 text-slate-500 hover:text-slate-300 text-sm transition-colors"
>
<LogOut className="w-4 h-4" />
</button>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="md:hidden text-slate-400"
>
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
{/* Mobile nav */}
{mobileOpen && (
<div className="md:hidden border-t border-slate-800/60 px-4 pb-4 space-y-1">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-emerald-500/10 text-emerald-400'
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
}`
}
>
<Icon className="w-4 h-4" />
{label}
</NavLink>
))}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-800/50 w-full"
>
<LogOut className="w-4 h-4" />
Sign out
</button>
</div>
)}
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,242 @@
import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import api, { type Category, type Transaction } from '../api';
interface Props {
transaction?: Transaction | null;
source: 'CREDIT_CARD' | 'CASH' | 'TRANSFER';
onClose: () => void;
onSaved: () => void;
}
export default function TransactionModal({ transaction, source, onClose, onSaved }: Props) {
const [categories, setCategories] = useState<Category[]>([]);
const [form, setForm] = useState({
merchant: '',
amount: '',
currency: 'CRC',
date: new Date().toISOString().slice(0, 16),
transaction_type: 'COMPRA',
source,
bank: 'BAC',
city: '',
card_type: '',
card_last4: '',
notes: '',
category_id: '' as string | number,
});
const [saving, setSaving] = useState(false);
useEffect(() => {
api.get('/categories/').then((r) => setCategories(r.data));
}, []);
useEffect(() => {
if (transaction) {
setForm({
merchant: transaction.merchant,
amount: String(transaction.amount),
currency: transaction.currency,
date: transaction.date.slice(0, 16),
transaction_type: transaction.transaction_type,
source: transaction.source as typeof source,
bank: transaction.bank,
city: transaction.city || '',
card_type: transaction.card_type || '',
card_last4: transaction.card_last4 || '',
notes: transaction.notes || '',
category_id: transaction.category_id || '',
});
}
}, [transaction]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const payload = {
...form,
amount: parseFloat(form.amount),
category_id: form.category_id ? Number(form.category_id) : null,
city: form.city || null,
card_type: form.card_type || null,
card_last4: form.card_last4 || null,
notes: form.notes || null,
};
if (transaction) {
await api.patch(`/transactions/${transaction.id}`, payload);
} else {
await api.post('/transactions/', payload);
}
onSaved();
onClose();
} catch (err) {
console.error(err);
} finally {
setSaving(false);
}
};
const inputClass =
'w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors';
const labelClass = 'block text-xs font-medium text-slate-400 mb-1 uppercase tracking-wider';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800/60">
<h3 className="font-semibold">
{transaction ? 'Edit Transaction' : 'New Transaction'}
</h3>
<button onClick={onClose} className="text-slate-500 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className={labelClass}>Merchant</label>
<input
className={inputClass}
value={form.merchant}
onChange={(e) => setForm({ ...form, merchant: e.target.value })}
placeholder="e.g. AUTO MERCADO ON LINE"
required
/>
</div>
<div>
<label className={labelClass}>Amount</label>
<input
className={inputClass}
type="number"
step="0.01"
value={form.amount}
onChange={(e) => setForm({ ...form, amount: e.target.value })}
placeholder="0.00"
required
/>
</div>
<div>
<label className={labelClass}>Currency</label>
<select
className={inputClass}
value={form.currency}
onChange={(e) => setForm({ ...form, currency: e.target.value })}
>
<option value="CRC">CRC ()</option>
<option value="USD">USD ($)</option>
</select>
</div>
<div>
<label className={labelClass}>Date</label>
<input
className={inputClass}
type="datetime-local"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
required
/>
</div>
<div>
<label className={labelClass}>Type</label>
<select
className={inputClass}
value={form.transaction_type}
onChange={(e) => setForm({ ...form, transaction_type: e.target.value })}
>
<option value="COMPRA">Compra</option>
<option value="DEVOLUCION">Devolución</option>
</select>
</div>
<div>
<label className={labelClass}>Category</label>
<select
className={inputClass}
value={form.category_id}
onChange={(e) => setForm({ ...form, category_id: e.target.value })}
>
<option value="">Auto-detect</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Bank</label>
<select
className={inputClass}
value={form.bank}
onChange={(e) => setForm({ ...form, bank: e.target.value })}
>
<option value="BAC">BAC</option>
<option value="BCR">BCR</option>
<option value="DAVIVIENDA">Davivienda</option>
</select>
</div>
<div>
<label className={labelClass}>City</label>
<input
className={inputClass}
value={form.city}
onChange={(e) => setForm({ ...form, city: e.target.value })}
placeholder="SAN JOSE, Costa Rica"
/>
</div>
{source === 'CREDIT_CARD' && (
<>
<div>
<label className={labelClass}>Card Type</label>
<input
className={inputClass}
value={form.card_type}
onChange={(e) => setForm({ ...form, card_type: e.target.value })}
placeholder="MASTER"
/>
</div>
<div>
<label className={labelClass}>Card Last 4</label>
<input
className={inputClass}
value={form.card_last4}
onChange={(e) => setForm({ ...form, card_last4: e.target.value })}
placeholder="6585"
maxLength={4}
/>
</div>
</>
)}
<div className="col-span-2">
<label className={labelClass}>Notes</label>
<input
className={inputClass}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Optional notes"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-slate-400 border border-slate-800 hover:bg-slate-800/50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-400 text-slate-950 transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : transaction ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1 +1,10 @@
@import 'tailwindcss';
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fade-in 0.4s ease-out both;
}

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>
);
}