mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 09:28:47 +02:00
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>
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Plus,
|
|
Search,
|
|
Pencil,
|
|
Trash2,
|
|
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);
|
|
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 [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);
|
|
try {
|
|
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, cycle]);
|
|
|
|
useEffect(() => {
|
|
api.get('/categories/').then((r) => setCategories(r.data));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(fetchTransactions, 300);
|
|
return () => clearTimeout(timer);
|
|
}, [fetchTransactions]);
|
|
|
|
const handleDelete = async () => {
|
|
if (deleteId === null) return;
|
|
setDeleting(true);
|
|
try {
|
|
await api.delete(`/transactions/${deleteId}`);
|
|
setDeleteId(null);
|
|
fetchTransactions();
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
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-text-muted mt-1">
|
|
{transactions.length} transactions
|
|
{totalCRC !== 0 && (
|
|
<> · <span className="font-mono text-text-primary">{formatAmount(totalCRC, 'CRC')}</span></>
|
|
)}
|
|
{totalUSD !== 0 && (
|
|
<> · <span className="font-mono text-text-primary">{formatAmount(totalUSD, 'USD')}</span></>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<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-text-muted" />
|
|
<input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
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>
|
|
<div className="relative">
|
|
<select
|
|
value={categoryFilter}
|
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
|
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) => (
|
|
<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-text-muted pointer-events-none" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<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-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-text-muted uppercase tracking-wider">
|
|
Merchant
|
|
</th>
|
|
<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-text-muted uppercase tracking-wider">
|
|
Amount
|
|
</th>
|
|
<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-border-subtle">
|
|
|
|
{transactions.map((tx) => (
|
|
<tr
|
|
key={tx.id}
|
|
className="hover:bg-surface-hover transition-colors group"
|
|
>
|
|
<td className="px-5 py-3 whitespace-nowrap">
|
|
<span className="font-mono text-text-secondary 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-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
|
|
: 'bg-red-500/10 text-red-500 dark: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-surface-hover text-text-secondary px-2 py-1 rounded">
|
|
{tx.category.name}
|
|
</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-[#606C38] dark:text-[#7a8a4a]'
|
|
: ''
|
|
}`}
|
|
>
|
|
{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-surface-hover text-text-muted hover:text-text-primary transition-colors"
|
|
>
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
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>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{transactions.length === 0 && !loading && (
|
|
<div className="px-5 py-16 text-center text-text-faint text-sm">
|
|
No transactions found
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{modalOpen && (
|
|
<TransactionModal
|
|
transaction={editing}
|
|
source="CREDIT_CARD"
|
|
onClose={() => setModalOpen(false)}
|
|
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>
|
|
);
|
|
}
|