mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
Add budget module: FastAPI backend + React frontend
Some checks failed
Deploy to VPS / deploy (push) Failing after 7s
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:
@@ -8,6 +8,16 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API to backend (same docker network)
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# Cache immutable assets
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
|
||||
@@ -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>© {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>
|
||||
);
|
||||
}
|
||||
|
||||
36
frontend/src/AuthContext.tsx
Normal file
36
frontend/src/AuthContext.tsx
Normal 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
69
frontend/src/api.ts
Normal 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;
|
||||
}
|
||||
118
frontend/src/components/Layout.tsx
Normal file
118
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
frontend/src/components/TransactionModal.tsx
Normal file
242
frontend/src/components/TransactionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
197
frontend/src/pages/Dashboard.tsx
Normal file
197
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
frontend/src/pages/Login.tsx
Normal file
89
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
235
frontend/src/pages/Transactions.tsx
Normal file
235
frontend/src/pages/Transactions.tsx
Normal 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 · 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>
|
||||
);
|
||||
}
|
||||
164
frontend/src/pages/Transfers.tsx
Normal file
164
frontend/src/pages/Transfers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user