Migrate all components and pages to shadcn/ui with DataTable
All checks were successful
Deploy to VPS / deploy (push) Successful in 28s

Replace custom markup across all pages and components with shadcn/ui
primitives (Dialog, Sheet, Select, Card, Tabs, etc.). Add reusable
DataTable component powered by @tanstack/react-table with sortable
column headers and client-side pagination. Introduce TransactionList
with responsive mobile cards and desktop DataTable, dashboard section
customization (DashboardSection, SectionConfigDialog), and settings
API types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-03-22 14:45:44 -06:00
parent 46f2d8679c
commit 2cd0d3b2e1
17 changed files with 1626 additions and 1109 deletions

View File

@@ -0,0 +1,195 @@
import { useState, useMemo } from 'react';
import {
Plus,
Search,
Pencil,
Trash2,
TrendingUp,
TrendingDown,
ArrowLeftRight,
} 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;
addLabel?: string;
}
export default function TransactionList({
transactions,
loading,
source,
search,
onSearchChange,
onRefresh,
emptyIcon,
emptyMessage = 'No transactions found',
showCategory = true,
addLabel = 'Add Transaction',
}: 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, onEdit: handleEdit, onDelete: (id) => setDeleteId(id) }),
[showCategory],
);
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">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
<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
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">
<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}
/>
)}
</>
);
}