Add transaction selection to import preview (#25)

Allow users to select individual transactions for import instead of
automatically importing all non-duplicate transactions. Adds checkboxes
to the preview table with select-all functionality and updates the
import logic to only process selected transactions.

<img width="1076" height="592" alt="image"
src="https://github.com/user-attachments/assets/06dab7d0-87f1-4c69-97a4-fa98b33062d7"
/>
This commit is contained in:
Víctor Falcón 2025-12-13 12:41:18 +01:00 committed by GitHub
parent be74d7844b
commit ec6b6d04cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 99 additions and 26 deletions

View File

@ -645,7 +645,7 @@ export function ImportBalancesDrawer({
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[90vh] data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
<div className="mx-auto w-full max-w-3xl overflow-y-auto p-6">
<div className="mx-auto w-full max-w-5xl overflow-y-auto p-6">
<DrawerHeader className="px-0">
<DrawerTitle>{stepInfo.title}</DrawerTitle>
<DrawerDescription>

View File

@ -1,5 +1,6 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
@ -16,6 +17,8 @@ interface ImportStepPreviewProps {
currencyCode: string;
onConfirm: () => void;
onBack: () => void;
onSelectionChange: (index: number, selected: boolean) => void;
onSelectAll: (selected: boolean) => void;
isImporting: boolean;
}
@ -24,12 +27,31 @@ export function ImportStepPreview({
currencyCode,
onConfirm,
onBack,
onSelectionChange,
onSelectAll,
isImporting,
}: ImportStepPreviewProps) {
const stats = useMemo(() => {
const newCount = transactions.filter((t) => !t.isDuplicate).length;
const selectableTransactions = transactions.filter(
(t) => !t.isDuplicate,
);
const selectedCount = selectableTransactions.filter(
(t) => t.selected,
).length;
const duplicateCount = transactions.filter((t) => t.isDuplicate).length;
return { newCount, duplicateCount, total: transactions.length };
const allSelected =
selectableTransactions.length > 0 &&
selectedCount === selectableTransactions.length;
const someSelected = selectedCount > 0 && !allSelected;
return {
selectedCount,
duplicateCount,
total: transactions.length,
selectableCount: selectableTransactions.length,
allSelected,
someSelected,
};
}, [transactions]);
const formatAmount = (amount: number): string => {
@ -48,6 +70,10 @@ export function ImportStepPreview({
});
};
const handleHeaderCheckboxChange = (checked: boolean) => {
onSelectAll(checked);
};
return (
<div className="flex flex-col gap-6">
<div className="flex gap-4 rounded-lg border bg-muted/50 p-4">
@ -56,9 +82,9 @@ export function ImportStepPreview({
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="flex-1">
<p className="text-sm text-muted-foreground">New</p>
<p className="text-sm text-muted-foreground">Selected</p>
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
{stats.newCount}
{stats.selectedCount}
</p>
</div>
<div className="flex-1">
@ -69,7 +95,7 @@ export function ImportStepPreview({
</div>
</div>
{stats.newCount === 0 && stats.duplicateCount > 0 && (
{stats.selectableCount === 0 && stats.duplicateCount > 0 && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 p-4">
<p className="text-sm text-amber-700 dark:text-amber-300">
All transactions appear to be duplicates. No new
@ -82,19 +108,31 @@ export function ImportStepPreview({
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead className="w-[50px]">
<Checkbox
checked={
stats.someSelected
? 'indeterminate'
: stats.allSelected
}
onCheckedChange={handleHeaderCheckboxChange}
disabled={stats.selectableCount === 0}
aria-label="Select all transactions"
/>
</TableHead>
<TableHead className="text-center">
Status
</TableHead>
<TableHead>Date</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
colSpan={5}
className="text-center text-muted-foreground"
>
No valid transactions found
@ -110,16 +148,20 @@ export function ImportStepPreview({
: ''
}
>
<TableCell className="whitespace-nowrap">
{formatDate(
transaction.transaction_date,
)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{transaction.description}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(transaction.amount)}
<TableCell>
<Checkbox
checked={
transaction.selected ?? false
}
onCheckedChange={(checked) =>
onSelectionChange(
index,
checked === true,
)
}
disabled={transaction.isDuplicate}
aria-label={`Select transaction: ${transaction.description}`}
/>
</TableCell>
<TableCell className="text-center">
{transaction.isDuplicate ? (
@ -135,6 +177,17 @@ export function ImportStepPreview({
</Badge>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
{formatDate(
transaction.transaction_date,
)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{transaction.description}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(transaction.amount)}
</TableCell>
</TableRow>
))
)}
@ -152,11 +205,11 @@ export function ImportStepPreview({
</Button>
<Button
onClick={onConfirm}
disabled={isImporting || stats.newCount === 0}
disabled={isImporting || stats.selectedCount === 0}
>
{isImporting
? 'Importing...'
: `Import ${stats.newCount} Transaction${stats.newCount !== 1 ? 's' : ''}`}
: `Import ${stats.selectedCount} Transaction${stats.selectedCount !== 1 ? 's' : ''}`}
</Button>
</div>
</div>

View File

@ -274,6 +274,7 @@ export function ImportTransactionsDrawer({
(transaction, index) => ({
...transaction,
isDuplicate: duplicateFlags[index],
selected: !duplicateFlags[index],
}),
);
@ -308,9 +309,7 @@ export function ImportTransactionsDrawer({
setError(null);
setImportErrors([]);
const newTransactions = state.transactions.filter(
(t) => !t.isDuplicate,
);
const newTransactions = state.transactions.filter((t) => t.selected);
const total = newTransactions.length;
setImportTotal(total);
setImportProgress(0);
@ -514,6 +513,24 @@ export function ImportTransactionsDrawer({
});
};
const handleSelectionChange = (index: number, selected: boolean) => {
setState((prev) => ({
...prev,
transactions: prev.transactions.map((t, i) =>
i === index ? { ...t, selected } : t,
),
}));
};
const handleSelectAll = (selected: boolean) => {
setState((prev) => ({
...prev,
transactions: prev.transactions.map((t) =>
t.isDuplicate ? t : { ...t, selected },
),
}));
};
const moveToStep = (step: ImportStep) => {
setState((prev) => ({ ...prev, step }));
};
@ -600,6 +617,8 @@ export function ImportTransactionsDrawer({
currencyCode={selectedAccount?.currency_code || 'USD'}
onConfirm={handleConfirmImport}
onBack={() => moveToStep(ImportStep.MapColumns)}
onSelectionChange={handleSelectionChange}
onSelectAll={handleSelectAll}
isImporting={isImporting}
/>
);
@ -687,7 +706,7 @@ export function ImportTransactionsDrawer({
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[90vh] data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
<div className="mx-auto w-full max-w-3xl overflow-y-auto p-6">
<div className="mx-auto w-full max-w-5xl overflow-y-auto p-6">
<DrawerHeader className="px-0">
<DrawerTitle>{stepInfo.title}</DrawerTitle>
<DrawerDescription>

View File

@ -28,6 +28,7 @@ export interface ParsedTransaction {
amount: number;
balance?: number | null;
isDuplicate?: boolean;
selected?: boolean;
validationErrors?: string[];
}