mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10: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 { 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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user