mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 11:08:47 +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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user