mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 09:08:46 +02:00
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:
@@ -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<ImportResult>('/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<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 {
|
||||
setImporting(false);
|
||||
}
|
||||
@@ -134,6 +170,39 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
|
||||
</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">
|
||||
Done
|
||||
</Button>
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [pendingDeletes, setPendingDeletes] = useState<Set<number>>(new Set());
|
||||
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) => {
|
||||
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({
|
||||
</Button>
|
||||
</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 */}
|
||||
<Card className="md:hidden">
|
||||
<CardContent className="p-0 divide-y divide-border">
|
||||
@@ -149,7 +296,7 @@ export default function TransactionList({
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
transactions.map((tx) => (
|
||||
visibleTransactions.map((tx) => (
|
||||
<div key={tx.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
className={cn(
|
||||
@@ -177,7 +324,7 @@ export default function TransactionList({
|
||||
)}
|
||||
</div>
|
||||
<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 && (
|
||||
<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 && (
|
||||
<ConfirmDialog
|
||||
title="Delete Transaction"
|
||||
|
||||
@@ -8,12 +8,20 @@ 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({
|
||||
@@ -22,14 +30,43 @@ export function getTransactionColumns({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleDeferred,
|
||||
selection,
|
||||
}: 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',
|
||||
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('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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user