Bulk selection UI and import-review modal

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>
This commit is contained in:
Carlos Escalante
2026-06-11 21:00:19 -06:00
parent ce3cc69846
commit 0dfac6a285
4 changed files with 291 additions and 10 deletions

View File

@@ -1,6 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react'; import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import api, { type ImportResult } from '@/lib/api'; import api, { type ImportResult } from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { formatShortDate } from '@/lib/dates';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -39,8 +43,40 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
const { data } = await api.post<ImportResult>('/import/paste', { text, bank, source }); const { data } = await api.post<ImportResult>('/import/paste', { text, bank, source });
setResult(data); setResult(data);
if (data.imported > 0) onImported(); if (data.imported > 0) onImported();
} catch (err) { } catch {
console.error(err); 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<ImportResult>('/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 { } finally {
setImporting(false); setImporting(false);
} }
@@ -134,6 +170,39 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
</Alert> </Alert>
)} )}
{result.skipped.length > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3 space-y-2">
<p className="text-sm font-medium">
{result.skipped.length} duplicado(s) omitido(s)
</p>
<ul className="text-xs max-h-40 overflow-y-auto space-y-1.5">
{result.skipped.map((s) => (
<li key={`${s.line}-${s.existing_id}`} className="space-y-0.5">
<span className="font-mono">
{s.merchant} · {formatShortDate(s.date)} ·{' '}
{formatAmount(s.amount, s.currency)}
</span>
<span className="block text-muted-foreground">
coincide con #{s.existing_id}: {s.existing_merchant} del{' '}
{formatShortDate(s.existing_date)} ({s.existing_source})
</span>
</li>
))}
</ul>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleImportDuplicates}
disabled={importing}
>
{importing
? 'Importando...'
: 'Importar duplicados de todos modos'}
</Button>
</div>
)}
<Button onClick={onClose} className="w-full"> <Button onClick={onClose} className="w-full">
Done Done
</Button> </Button>

View File

@@ -13,13 +13,22 @@ import {
import { toast } from 'sonner'; 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 TransactionModal from './TransactionModal';
import ConfirmDialog from './ConfirmDialog'; import ConfirmDialog from './ConfirmDialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { DataTable } from '@/components/ui/data-table'; 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 { getTransactionColumns } from '@/components/transactions/transaction-columns';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -58,6 +67,40 @@ export default function TransactionList({
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
const [pendingDeletes, setPendingDeletes] = useState<Set<number>>(new Set()); const [pendingDeletes, setPendingDeletes] = useState<Set<number>>(new Set());
const deleteTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map()); const deleteTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkConfirm, setBulkConfirm] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const categoriesQ = useQuery({
queryKey: ['categories'],
queryFn: ({ signal }) =>
api.get<Category[]>('/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<string, unknown>,
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) => { const handleEdit = (tx: Transaction) => {
setEditing(tx); 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( 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 const visibleTransactions = pendingDeletes.size
@@ -140,6 +209,84 @@ export default function TransactionList({
</Button> </Button>
</div> </div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="hidden md:flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
<span className="text-sm font-medium">
{selected.size} seleccionada{selected.size === 1 ? '' : 's'}
</span>
<div className="ml-auto flex items-center gap-2">
<Select
onValueChange={(v) => {
if (!v) return;
void runBulk(
{
action: 'set_category',
category_id: v === '__none__' ? null : Number(v),
},
'Categoría aplicada',
);
}}
>
<SelectTrigger className="h-8 w-44" disabled={bulkBusy}>
<SelectValue placeholder="Asignar categoría…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Sin categoría</SelectItem>
{(categoriesQ.data ?? []).map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{onToggleDeferred && (
<>
<Button
variant="outline"
size="sm"
disabled={bulkBusy}
onClick={() =>
void runBulk(
{ action: 'set_deferred', deferred: true },
'Transacciones diferidas',
)
}
>
Diferir
</Button>
<Button
variant="outline"
size="sm"
disabled={bulkBusy}
onClick={() =>
void runBulk(
{ action: 'set_deferred', deferred: false },
'Diferidas restauradas',
)
}
>
Restaurar
</Button>
</>
)}
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
disabled={bulkBusy}
onClick={() => setBulkConfirm(true)}
>
<Trash2 className="w-4 h-4 mr-1" aria-hidden="true" />
Eliminar
</Button>
<Button variant="ghost" size="sm" onClick={clearSelection}>
Cancelar
</Button>
</div>
</div>
)}
{/* Mobile list */} {/* Mobile list */}
<Card className="md:hidden"> <Card className="md:hidden">
<CardContent className="p-0 divide-y divide-border"> <CardContent className="p-0 divide-y divide-border">
@@ -149,7 +296,7 @@ export default function TransactionList({
{emptyMessage} {emptyMessage}
</div> </div>
) : ( ) : (
transactions.map((tx) => ( visibleTransactions.map((tx) => (
<div key={tx.id} className="flex items-center gap-3 px-4 py-3"> <div key={tx.id} className="flex items-center gap-3 px-4 py-3">
<div <div
className={cn( className={cn(
@@ -177,7 +324,7 @@ export default function TransactionList({
)} )}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{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 && ( {showCategory && tx.category && (
<span className="ml-1.5 text-muted-foreground/60">{tx.category.name}</span> <span className="ml-1.5 text-muted-foreground/60">{tx.category.name}</span>
)} )}
@@ -250,6 +397,20 @@ export default function TransactionList({
/> />
)} )}
{bulkConfirm && (
<ConfirmDialog
title={`Eliminar ${selected.size} transacciones`}
message="Esta acción no se puede deshacer."
confirmLabel="Eliminar todas"
loading={bulkBusy}
onConfirm={async () => {
await runBulk({ action: 'delete' }, 'Transacciones eliminadas');
setBulkConfirm(false);
}}
onCancel={() => setBulkConfirm(false)}
/>
)}
{deleteId !== null && ( {deleteId !== null && (
<ConfirmDialog <ConfirmDialog
title="Delete Transaction" title="Delete Transaction"

View File

@@ -8,12 +8,20 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'; 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 { interface TransactionColumnOptions {
showCategory: boolean; showCategory: boolean;
showSourceIcon?: boolean; showSourceIcon?: boolean;
onEdit: (tx: Transaction) => void; onEdit: (tx: Transaction) => void;
onDelete: (txId: number) => void; onDelete: (txId: number) => void;
onToggleDeferred?: (tx: Transaction) => void; onToggleDeferred?: (tx: Transaction) => void;
selection?: RowSelection;
} }
export function getTransactionColumns({ export function getTransactionColumns({
@@ -22,14 +30,43 @@ export function getTransactionColumns({
onEdit, onEdit,
onDelete, onDelete,
onToggleDeferred, onToggleDeferred,
selection,
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] { }: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
const columns: 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', accessorKey: 'date',
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />, header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-muted-foreground text-xs whitespace-nowrap"> <span className="font-mono text-muted-foreground text-xs whitespace-nowrap">
{new Date(row.original.date).toLocaleDateString('en-US', { {new Date(row.original.date).toLocaleDateString('es-CR', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -74,7 +111,7 @@ export function getTransactionColumns({
); );
}, },
}, },
]; );
if (showCategory) { if (showCategory) {
columns.push({ columns.push({

View File

@@ -143,10 +143,24 @@ export interface Category {
auto_match_patterns: string | null; 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 { export interface ImportResult {
imported: number; imported: number;
duplicates: number; duplicates: number;
errors: string[]; errors: string[];
skipped: SkippedDuplicate[];
} }
export interface Transaction { export interface Transaction {