mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 16:08:47 +02:00
Adds deferred_to_next_cycle flag to transactions for billing cycle bleed-over handling. Overhauls budget projection engine and refreshes Budget page with improved monthly detail and transaction columns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
224 lines
7.6 KiB
TypeScript
224 lines
7.6 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import {
|
|
Plus,
|
|
Search,
|
|
Pencil,
|
|
Trash2,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
ArrowLeftRight,
|
|
ArrowRightFromLine,
|
|
Banknote,
|
|
} from 'lucide-react';
|
|
|
|
import api, { type Transaction } from '../api';
|
|
import TransactionModal from './TransactionModal';
|
|
import ConfirmDialog from './ConfirmDialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { DataTable } from '@/components/ui/data-table';
|
|
import { getTransactionColumns } from '@/components/transactions/transaction-columns';
|
|
import { formatAmount } from '@/lib/format';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface TransactionListProps {
|
|
transactions: Transaction[];
|
|
loading: boolean;
|
|
source: 'CREDIT_CARD' | 'CASH' | 'TRANSFER';
|
|
search: string;
|
|
onSearchChange: (value: string) => void;
|
|
onRefresh: () => void;
|
|
emptyIcon?: React.ReactNode;
|
|
emptyMessage?: string;
|
|
showCategory?: boolean;
|
|
showSourceIcon?: boolean;
|
|
addLabel?: string;
|
|
onToggleDeferred?: (tx: Transaction) => void;
|
|
}
|
|
|
|
export default function TransactionList({
|
|
transactions,
|
|
loading,
|
|
source,
|
|
search,
|
|
onSearchChange,
|
|
onRefresh,
|
|
emptyIcon,
|
|
emptyMessage = 'No transactions found',
|
|
showCategory = true,
|
|
showSourceIcon = false,
|
|
addLabel = 'Add Transaction',
|
|
onToggleDeferred,
|
|
}: TransactionListProps) {
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editing, setEditing] = useState<Transaction | null>(null);
|
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
const handleEdit = (tx: Transaction) => {
|
|
setEditing(tx);
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (deleteId === null) return;
|
|
setDeleting(true);
|
|
try {
|
|
await api.delete(`/transactions/${deleteId}`);
|
|
setDeleteId(null);
|
|
onRefresh();
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const columns = useMemo(
|
|
() => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }),
|
|
[showCategory, showSourceIcon, onToggleDeferred],
|
|
);
|
|
|
|
const empty = transactions.length === 0 && !loading;
|
|
|
|
return (
|
|
<>
|
|
{/* Search + Add */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="pl-10"
|
|
placeholder="Search merchants..."
|
|
/>
|
|
</div>
|
|
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
|
|
<Plus className="w-4 h-4" />
|
|
{addLabel}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Mobile list */}
|
|
<Card className="md:hidden">
|
|
<CardContent className="p-0 divide-y divide-border">
|
|
{empty ? (
|
|
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
|
|
{emptyIcon || <ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
|
|
{emptyMessage}
|
|
</div>
|
|
) : (
|
|
transactions.map((tx) => (
|
|
<div key={tx.id} className="flex items-center gap-3 px-4 py-3">
|
|
<div
|
|
className={cn(
|
|
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
|
tx.transaction_type === 'DEVOLUCION'
|
|
? 'bg-primary/10 text-primary'
|
|
: 'bg-destructive/10 text-destructive'
|
|
)}
|
|
>
|
|
{tx.transaction_type === 'DEVOLUCION' ? (
|
|
<TrendingUp className="w-4 h-4" />
|
|
) : (
|
|
<TrendingDown className="w-4 h-4" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1">
|
|
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
|
{showSourceIcon && (
|
|
tx.source === 'CASH'
|
|
? <Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
: tx.source === 'TRANSFER'
|
|
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
: null
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
|
{showCategory && tx.category && (
|
|
<span className="ml-1.5 text-muted-foreground/60">{tx.category.name}</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<span
|
|
data-sensitive
|
|
className={cn(
|
|
'font-mono text-sm font-medium shrink-0',
|
|
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
|
|
)}
|
|
>
|
|
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
|
{formatAmount(tx.amount, tx.currency)}
|
|
</span>
|
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
{onToggleDeferred && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title={tx.deferred_to_next_cycle ? 'Quitar diferida' : 'Diferir al siguiente ciclo'}
|
|
aria-label={tx.deferred_to_next_cycle ? 'Remove deferred' : 'Defer to next cycle'}
|
|
onClick={() => onToggleDeferred(tx)}
|
|
className={cn(tx.deferred_to_next_cycle && 'text-amber-600')}
|
|
>
|
|
<ArrowRightFromLine className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Delete transaction"
|
|
aria-label="Delete transaction"
|
|
onClick={() => setDeleteId(tx.id)}
|
|
className="hover:text-destructive"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Desktop table */}
|
|
<Card className="hidden md:block">
|
|
<CardContent className="p-0">
|
|
<DataTable
|
|
columns={columns}
|
|
data={transactions}
|
|
pagination
|
|
pageSize={25}
|
|
initialSorting={[{ id: 'date', desc: true }]}
|
|
emptyMessage={emptyMessage}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Modals */}
|
|
{modalOpen && (
|
|
<TransactionModal
|
|
transaction={editing}
|
|
source={source}
|
|
onClose={() => setModalOpen(false)}
|
|
onSaved={onRefresh}
|
|
/>
|
|
)}
|
|
|
|
{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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|