mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:08:46 +02:00
Transactions gain a checkbox column with select-all; a bulk bar offers assign-category, defer/restore and delete (confirmed) via the new bulk endpoint, with toasts and invalidation. The paste-import modal now lists each skipped duplicate with the matched existing row and offers 'Importar duplicados de todos modos' — re-importing ONLY the skipped lines so first-pass rows can't double-import (UX-20). Two real fixes found en route: the mobile list ignored undo-pending deletes and used en-US dates; the desktop date column also lost its en-US stray. Note: UX-11 (no mobile layout) was a false positive — a card layout already existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
204 lines
6.4 KiB
TypeScript
204 lines
6.4 KiB
TypeScript
import { type ColumnDef } from '@tanstack/react-table';
|
|
import { ArrowLeftRight, ArrowRightFromLine, Banknote, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
|
|
|
import { type Transaction } from '@/lib/api';
|
|
import { formatAmount } from '@/lib/format';
|
|
import { cn } from '@/lib/utils';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
|
|
|
|
export interface RowSelection {
|
|
selected: Set<number>;
|
|
allSelected: boolean;
|
|
onToggle: (id: number) => void;
|
|
onToggleAll: () => void;
|
|
}
|
|
|
|
interface TransactionColumnOptions {
|
|
showCategory: boolean;
|
|
showSourceIcon?: boolean;
|
|
onEdit: (tx: Transaction) => void;
|
|
onDelete: (txId: number) => void;
|
|
onToggleDeferred?: (tx: Transaction) => void;
|
|
selection?: RowSelection;
|
|
}
|
|
|
|
export function getTransactionColumns({
|
|
showCategory,
|
|
showSourceIcon,
|
|
onEdit,
|
|
onDelete,
|
|
onToggleDeferred,
|
|
selection,
|
|
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
|
const columns: ColumnDef<Transaction, unknown>[] = [];
|
|
|
|
if (selection) {
|
|
columns.push({
|
|
id: 'select',
|
|
size: 36,
|
|
enableSorting: false,
|
|
header: () => (
|
|
<input
|
|
type="checkbox"
|
|
className="accent-primary w-4 h-4 cursor-pointer align-middle"
|
|
aria-label="Seleccionar todas las transacciones"
|
|
checked={selection.allSelected}
|
|
onChange={selection.onToggleAll}
|
|
/>
|
|
),
|
|
cell: ({ row }) => (
|
|
<input
|
|
type="checkbox"
|
|
className="accent-primary w-4 h-4 cursor-pointer align-middle"
|
|
aria-label={`Seleccionar ${row.original.merchant}`}
|
|
checked={selection.selected.has(row.original.id)}
|
|
onChange={() => selection.onToggle(row.original.id)}
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
columns.push(
|
|
{
|
|
accessorKey: 'date',
|
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
|
|
cell: ({ row }) => (
|
|
<span className="font-mono text-muted-foreground text-xs whitespace-nowrap">
|
|
{new Date(row.original.date).toLocaleDateString('es-CR', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'merchant',
|
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Merchant" />,
|
|
cell: ({ row }) => {
|
|
const tx = row.original;
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={cn(
|
|
'w-6 h-6 rounded flex items-center justify-center shrink-0',
|
|
tx.transaction_type === 'COMPRA'
|
|
? 'bg-destructive/10 text-destructive'
|
|
: 'bg-primary/10 text-primary',
|
|
)}
|
|
>
|
|
{tx.transaction_type === 'COMPRA' ? (
|
|
<TrendingDown className="w-3 h-3" />
|
|
) : (
|
|
<TrendingUp className="w-3 h-3" />
|
|
)}
|
|
</div>
|
|
<span className="truncate">{tx.merchant}</span>
|
|
{showSourceIcon && tx.source === 'CASH' && (
|
|
<Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
|
|
)}
|
|
{showSourceIcon && tx.source === 'TRANSFER' && (
|
|
<ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
|
|
)}
|
|
{tx.deferred_to_next_cycle && (
|
|
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-amber-600 border-amber-300">
|
|
Diferida
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
);
|
|
|
|
if (showCategory) {
|
|
columns.push({
|
|
accessorFn: (row) => row.category?.name ?? '',
|
|
id: 'category',
|
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Category" />,
|
|
cell: ({ row }) => {
|
|
const category = row.original.category;
|
|
return category ? (
|
|
<Badge variant="secondary">{category.name}</Badge>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">—</span>
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
columns.push(
|
|
{
|
|
accessorKey: 'amount',
|
|
meta: { className: 'text-right' },
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Amount" className="justify-end" />
|
|
),
|
|
cell: ({ row }) => {
|
|
const tx = row.original;
|
|
return (
|
|
<span
|
|
data-sensitive
|
|
className={cn(
|
|
'font-mono font-medium',
|
|
tx.transaction_type !== 'COMPRA' && 'text-primary',
|
|
tx.deferred_to_next_cycle && 'opacity-50 line-through decoration-muted-foreground/60',
|
|
)}
|
|
>
|
|
{tx.transaction_type === 'COMPRA' ? '-' : '+'}
|
|
{formatAmount(tx.amount, tx.currency)}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'actions',
|
|
meta: { className: 'text-right' },
|
|
size: 80,
|
|
enableSorting: false,
|
|
cell: ({ row }) => {
|
|
const tx = row.original;
|
|
return (
|
|
<div className="flex items-center justify-end gap-1">
|
|
{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={() => onEdit(tx)}
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Delete transaction"
|
|
aria-label="Delete transaction"
|
|
onClick={() => onDelete(tx.id)}
|
|
className="hover:text-destructive"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
);
|
|
|
|
return columns;
|
|
}
|