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:
parent
be74d7844b
commit
ec6b6d04cf
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface ParsedTransaction {
|
|||
amount: number;
|
||||
balance?: number | null;
|
||||
isDuplicate?: boolean;
|
||||
selected?: boolean;
|
||||
validationErrors?: string[];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue