diff --git a/frontend/src/components/PasteImportModal.tsx b/frontend/src/components/PasteImportModal.tsx index 53b40db..c6547e3 100644 --- a/frontend/src/components/PasteImportModal.tsx +++ b/frontend/src/components/PasteImportModal.tsx @@ -1,6 +1,10 @@ import { useState } from 'react'; import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react'; +import { toast } from 'sonner'; + import api, { type ImportResult } from '@/lib/api'; +import { formatAmount } from '@/lib/format'; +import { formatShortDate } from '@/lib/dates'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; @@ -39,8 +43,40 @@ export default function PasteImportModal({ onClose, onImported }: Props) { const { data } = await api.post('/import/paste', { text, bank, source }); setResult(data); if (data.imported > 0) onImported(); - } catch (err) { - console.error(err); + } catch { + toast.error('No se pudo importar - revisa el formato'); + } finally { + setImporting(false); + } + }; + + // Re-import ONLY the skipped lines with the dedup check disabled, so rows + // that imported on the first pass are never duplicated. + const handleImportDuplicates = async () => { + if (!result || result.skipped.length === 0) return; + const lines = text.trim().split('\n'); + const dupText = result.skipped + .map((s) => lines[s.line - 1]) + .filter(Boolean) + .join('\n'); + setImporting(true); + try { + const { data } = await api.post('/import/paste', { + text: dupText, + bank, + source, + allow_duplicates: true, + }); + toast.success(`${data.imported} duplicado(s) importados`); + setResult({ + ...result, + imported: result.imported + data.imported, + skipped: [], + duplicates: 0, + }); + onImported(); + } catch { + toast.error('No se pudieron importar los duplicados'); } finally { setImporting(false); } @@ -134,6 +170,39 @@ export default function PasteImportModal({ onClose, onImported }: Props) { )} + {result.skipped.length > 0 && ( +
+

+ {result.skipped.length} duplicado(s) omitido(s) +

+
    + {result.skipped.map((s) => ( +
  • + + {s.merchant} · {formatShortDate(s.date)} ·{' '} + {formatAmount(s.amount, s.currency)} + + + coincide con #{s.existing_id}: {s.existing_merchant} del{' '} + {formatShortDate(s.existing_date)} ({s.existing_source}) + +
  • + ))} +
+ +
+ )} + diff --git a/frontend/src/components/TransactionList.tsx b/frontend/src/components/TransactionList.tsx index e073cc3..73c62c0 100644 --- a/frontend/src/components/TransactionList.tsx +++ b/frontend/src/components/TransactionList.tsx @@ -13,13 +13,22 @@ import { import { toast } from 'sonner'; -import api, { type Transaction } from '@/lib/api'; +import { useQuery } from '@tanstack/react-query'; + +import api, { type Category, type Transaction } from '@/lib/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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { getTransactionColumns } from '@/components/transactions/transaction-columns'; import { formatAmount } from '@/lib/format'; import { cn } from '@/lib/utils'; @@ -58,6 +67,40 @@ export default function TransactionList({ const [deleteId, setDeleteId] = useState(null); const [pendingDeletes, setPendingDeletes] = useState>(new Set()); const deleteTimers = useRef>>(new Map()); + const [selected, setSelected] = useState>(new Set()); + const [bulkConfirm, setBulkConfirm] = useState(false); + const [bulkBusy, setBulkBusy] = useState(false); + + const categoriesQ = useQuery({ + queryKey: ['categories'], + queryFn: ({ signal }) => + api.get('/categories/', { signal }).then((r) => + [...r.data].sort((a, b) => a.name.localeCompare(b.name)), + ), + enabled: selected.size > 0, + }); + + const clearSelection = () => setSelected(new Set()); + + const runBulk = async ( + payload: Record, + success: string, + ) => { + setBulkBusy(true); + try { + const { data } = await api.post<{ affected: number }>( + '/transactions/bulk', + { ids: [...selected], ...payload }, + ); + toast.success(`${success} (${data.affected})`); + clearSelection(); + onRefresh(); + } catch { + toast.error('No se pudo aplicar la acción masiva'); + } finally { + setBulkBusy(false); + } + }; const handleEdit = (tx: Transaction) => { setEditing(tx); @@ -111,9 +154,35 @@ export default function TransactionList({ ); }; + const allSelected = + transactions.length > 0 && transactions.every((t) => selected.has(t.id)); + const columns = useMemo( - () => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }), - [showCategory, showSourceIcon, onToggleDeferred], + () => + getTransactionColumns({ + showCategory, + showSourceIcon, + onEdit: handleEdit, + onDelete: (id) => setDeleteId(id), + onToggleDeferred, + selection: { + selected, + allSelected, + onToggle: (id) => + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }), + onToggleAll: () => + setSelected( + allSelected ? new Set() : new Set(transactions.map((t) => t.id)), + ), + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [showCategory, showSourceIcon, onToggleDeferred, selected, allSelected, transactions], ); const visibleTransactions = pendingDeletes.size @@ -140,6 +209,84 @@ export default function TransactionList({ + {/* Bulk action bar */} + {selected.size > 0 && ( +
+ + {selected.size} seleccionada{selected.size === 1 ? '' : 's'} + +
+ + {onToggleDeferred && ( + <> + + + + )} + + +
+
+ )} + {/* Mobile list */} @@ -149,7 +296,7 @@ export default function TransactionList({ {emptyMessage} ) : ( - transactions.map((tx) => ( + visibleTransactions.map((tx) => (

- {new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + {new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })} {showCategory && tx.category && ( {tx.category.name} )} @@ -250,6 +397,20 @@ export default function TransactionList({ /> )} + {bulkConfirm && ( + { + await runBulk({ action: 'delete' }, 'Transacciones eliminadas'); + setBulkConfirm(false); + }} + onCancel={() => setBulkConfirm(false)} + /> + )} + {deleteId !== null && ( ; + 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({ @@ -22,14 +30,43 @@ export function getTransactionColumns({ onEdit, onDelete, onToggleDeferred, + selection, }: TransactionColumnOptions): ColumnDef[] { - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = []; + + if (selection) { + columns.push({ + id: 'select', + size: 36, + enableSorting: false, + header: () => ( + + ), + cell: ({ row }) => ( + selection.onToggle(row.original.id)} + /> + ), + }); + } + + columns.push( { accessorKey: 'date', header: ({ column }) => , cell: ({ row }) => ( - {new Date(row.original.date).toLocaleDateString('en-US', { + {new Date(row.original.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric', year: 'numeric', @@ -74,7 +111,7 @@ export function getTransactionColumns({ ); }, }, - ]; + ); if (showCategory) { columns.push({ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 272e54d..3661185 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -143,10 +143,24 @@ export interface Category { auto_match_patterns: string | null; } +export interface SkippedDuplicate { + line: number; + merchant: string; + date: string; + amount: number; + currency: string; + existing_id: number; + existing_merchant: string; + existing_date: string; + existing_amount: number; + existing_source: string; +} + export interface ImportResult { imported: number; duplicates: number; errors: string[]; + skipped: SkippedDuplicate[]; } export interface Transaction {