Add accounts expansion, analytics, exchange rates, API tokens, PWA support, and UI overhaul
All checks were successful
Deploy to VPS / deploy (push) Successful in 45s

- Expand Account model with account_type (pension, savings, liability, crypto), new banks/currencies (BTC, XMR, FCL, ROP, VOL, MEMP, MPAT, MORTGAGE), and next_payment field
- Add exchange rate endpoint (BCCR integration), analytics endpoint, paste-import for transactions, and API token management
- Add PWA manifest, service worker, and app icons
- Redesign dashboard, transactions, transfers, and login pages with theme support
- Add billing cycle selector, confirm dialog, and paste import modal components
- One-time DB reset in deploy workflow for schema migration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-03-21 18:23:47 -06:00
parent 1257b0dd61
commit 0a8e00e227
39 changed files with 2247 additions and 220 deletions

View File

@@ -7,10 +7,14 @@ import {
TrendingUp,
TrendingDown,
ChevronDown,
ClipboardPaste,
} from 'lucide-react';
import api, { type Transaction, type Category } from '../api';
import TransactionModal from '../components/TransactionModal';
import PasteImportModal from '../components/PasteImportModal';
import ConfirmDialog from '../components/ConfirmDialog';
import BillingCycleSelector from '../components/BillingCycleSelector';
function formatAmount(amount: number, currency: string) {
const abs = Math.abs(amount);
@@ -27,7 +31,11 @@ export default function Transactions() {
const [categoryFilter, setCategoryFilter] = useState('');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
const fetchTransactions = useCallback(async () => {
setLoading(true);
@@ -35,12 +43,16 @@ export default function Transactions() {
const params: Record<string, string> = { source: 'CREDIT_CARD', limit: '200' };
if (search) params.search = search;
if (categoryFilter) params.category_id = categoryFilter;
if (cycle) {
params.cycle_year = String(cycle.year);
params.cycle_month = String(cycle.month);
}
const { data } = await api.get('/transactions/', { params });
setTransactions(data);
} finally {
setLoading(false);
}
}, [search, categoryFilter]);
}, [search, categoryFilter, cycle]);
useEffect(() => {
api.get('/categories/').then((r) => setCategories(r.data));
@@ -51,47 +63,72 @@ export default function Transactions() {
return () => clearTimeout(timer);
}, [fetchTransactions]);
const handleDelete = async (id: number) => {
if (!confirm('Delete this transaction?')) return;
await api.delete(`/transactions/${id}`);
fetchTransactions();
const handleDelete = async () => {
if (deleteId === null) return;
setDeleting(true);
try {
await api.delete(`/transactions/${deleteId}`);
setDeleteId(null);
fetchTransactions();
} finally {
setDeleting(false);
}
};
const total = transactions.reduce((sum, tx) => {
const signed = tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount;
return sum + signed;
}, 0);
const totalCRC = transactions
.filter((tx) => tx.currency === 'CRC')
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
const totalUSD = transactions
.filter((tx) => tx.currency === 'USD')
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 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 className="text-sm text-text-muted mt-1">
{transactions.length} transactions
{totalCRC !== 0 && (
<> &middot; <span className="font-mono text-text-primary">{formatAmount(totalCRC, 'CRC')}</span></>
)}
{totalUSD !== 0 && (
<> &middot; <span className="font-mono text-text-primary">{formatAmount(totalUSD, 'USD')}</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 className="flex gap-2">
<button
onClick={() => setImportOpen(true)}
className="flex items-center gap-2 border border-border hover:bg-surface-hover text-text-secondary font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
>
<ClipboardPaste className="w-4 h-4" />
Import
</button>
<button
onClick={() => {
setEditing(null);
setModalOpen(true);
}}
className="flex items-center gap-2 bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
>
<Plus className="w-4 h-4" />
Add Transaction
</button>
</div>
</div>
{/* Billing cycle */}
<BillingCycleSelector value={cycle} onChange={setCycle} />
{/* 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" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<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"
className="w-full bg-input-bg border border-border rounded-lg pl-10 pr-4 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 transition-colors"
placeholder="Search merchants..."
/>
</div>
@@ -99,7 +136,7 @@ export default function Transactions() {
<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"
className="appearance-none bg-input-bg border border-border rounded-lg pl-4 pr-10 py-2.5 text-sm text-text-primary focus:outline-none focus:border-[#606C38]/50 transition-colors"
>
<option value="">All Categories</option>
{categories.map((c) => (
@@ -108,42 +145,42 @@ export default function Transactions() {
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" />
</div>
</div>
{/* Table */}
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl overflow-hidden">
<div className="bg-surface-card border border-border 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">
<tr className="border-b border-border-subtle">
<th className="text-left px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
Date
</th>
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider">
<th className="text-left px-5 py-3 text-xs font-medium text-text-muted 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">
<th className="text-left px-5 py-3 text-xs font-medium text-text-muted 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">
<th className="text-right px-5 py-3 text-xs font-medium text-text-muted 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">
<th className="text-right px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider w-20">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
<tbody className="divide-y divide-border-subtle">
{transactions.map((tx) => (
<tr
key={tx.id}
className="hover:bg-slate-800/20 transition-colors group"
className="hover:bg-surface-hover transition-colors group"
>
<td className="px-5 py-3 whitespace-nowrap">
<span className="font-mono text-slate-400 text-xs">
<span className="font-mono text-text-secondary text-xs">
{new Date(tx.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
@@ -156,8 +193,8 @@ export default function Transactions() {
<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'
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
: 'bg-red-500/10 text-red-500 dark:text-red-400'
}`}
>
{tx.transaction_type === 'DEVOLUCION' ? (
@@ -171,19 +208,19 @@ export default function Transactions() {
</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">
<span className="text-xs bg-surface-hover text-text-secondary px-2 py-1 rounded">
{tx.category.name}
</span>
) : (
<span className="text-xs text-slate-600"></span>
<span className="text-xs text-text-faint"></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'
? 'text-[#606C38] dark:text-[#7a8a4a]'
: ''
}`}
>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
@@ -197,13 +234,13 @@ export default function Transactions() {
setEditing(tx);
setModalOpen(true);
}}
className="p-1.5 rounded hover:bg-slate-700/50 text-slate-500 hover:text-white transition-colors"
className="p-1.5 rounded hover:bg-surface-hover text-text-muted hover:text-text-primary 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"
onClick={() => setDeleteId(tx.id)}
className="p-1.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@@ -216,7 +253,7 @@ export default function Transactions() {
</div>
{transactions.length === 0 && !loading && (
<div className="px-5 py-16 text-center text-slate-600 text-sm">
<div className="px-5 py-16 text-center text-text-faint text-sm">
No transactions found
</div>
)}
@@ -230,6 +267,23 @@ export default function Transactions() {
onSaved={fetchTransactions}
/>
)}
{importOpen && (
<PasteImportModal
onClose={() => setImportOpen(false)}
onImported={fetchTransactions}
/>
)}
{deleteId !== null && (
<ConfirmDialog
title="Delete Transaction"
message="This transaction will be permanently deleted. This action cannot be undone."
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
loading={deleting}
/>
)}
</div>
);
}