whisper-money/resources/js/components/transactions/import-step-preview.tsx

351 lines
15 KiB
TypeScript

import { TransactionDescription } from '@/components/transactions/transaction-description';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useLocale } from '@/hooks/use-locale';
import { type ParsedTransaction } from '@/types/import';
import { type Transaction } from '@/types/transaction';
import { formatDateMedium } from '@/utils/date';
import { __ } from '@/utils/i18n';
import axios from 'axios';
import { ChevronDown } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
interface ImportStepPreviewProps {
transactions: ParsedTransaction[];
currencyCode: string;
accountId: string;
onConfirm: () => void;
onBack: () => void;
onSelectionChange: (index: number, selected: boolean) => void;
onSelectAll: (selected: boolean) => void;
isImporting: boolean;
}
export function ImportStepPreview({
transactions,
currencyCode,
accountId,
onConfirm,
onBack,
onSelectionChange,
onSelectAll,
isImporting,
}: ImportStepPreviewProps) {
const locale = useLocale();
const [existingTransactions, setExistingTransactions] = useState<
Transaction[]
>([]);
const [isExistingOpen, setIsExistingOpen] = useState(false);
useEffect(() => {
if (!accountId) {
return;
}
axios
.get('/api/transactions', {
params: { account_id: accountId, per_page: 10 },
})
.then((response) => {
setExistingTransactions(response.data.data ?? []);
})
.catch((error) => {
console.error('Failed to load existing transactions:', error);
setExistingTransactions([]);
});
}, [accountId]);
const stats = useMemo(() => {
const selectableTransactions = transactions.filter(
(t) => !t.isDuplicate,
);
const selectedCount = selectableTransactions.filter(
(t) => t.selected,
).length;
const duplicateCount = transactions.filter((t) => t.isDuplicate).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 handleHeaderCheckboxChange = (checked: boolean) => {
onSelectAll(checked);
};
const hasBalances = useMemo(
() =>
transactions.some(
(t) => t.balance !== null && t.balance !== undefined,
),
[transactions],
);
return (
<div className="flex flex-col gap-6">
<div className="flex gap-4 rounded-lg border bg-muted/50 p-4">
<div className="flex-1">
<p className="text-sm text-muted-foreground">
{__('Total')}
</p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="flex-1">
<p className="text-sm text-muted-foreground">
{__('Selected')}
</p>
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
{stats.selectedCount}
</p>
</div>
<div className="flex-1">
<p className="text-sm text-muted-foreground">
{__('Duplicates')}
</p>
<p className="text-2xl font-bold text-amber-600 dark:text-amber-400">
{stats.duplicateCount}
</p>
</div>
</div>
{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 transactions will be imported.',
)}
</p>
</div>
)}
<div className="max-h-[400px] overflow-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<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>
{hasBalances && (
<TableHead className="text-right">
{__('Balance')}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{transactions.length === 0 ? (
<TableRow>
<TableCell
colSpan={hasBalances ? 6 : 5}
className="text-center text-muted-foreground"
>
No valid transactions found
</TableCell>
</TableRow>
) : (
transactions.map((transaction, index) => (
<TableRow
key={index}
className={
transaction.isDuplicate
? 'opacity-60'
: ''
}
>
<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 ? (
<Badge variant="secondary">
{__('Duplicate')}
</Badge>
) : (
<Badge
variant="secondary"
className="bg-green-50 text-green-600 dark:bg-green-900 dark:text-green-600"
>
{__('New')}
</Badge>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateMedium(
transaction.transaction_date,
locale,
)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{transaction.description}
</TableCell>
<TableCell className="text-right font-mono">
<AmountDisplay
amountInCents={transaction.amount}
currencyCode={currencyCode}
/>
</TableCell>
{hasBalances && (
<TableCell className="text-right font-mono">
{transaction.balance !== null &&
transaction.balance !==
undefined ? (
<AmountDisplay
amountInCents={
transaction.balance
}
currencyCode={currencyCode}
/>
) : (
<span className="text-muted-foreground">
</span>
)}
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{existingTransactions.length > 0 && (
<Collapsible
open={isExistingOpen}
onOpenChange={setIsExistingOpen}
className="rounded-lg border border-sidebar-border bg-sidebar p-1"
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="flex w-full cursor-pointer items-center justify-between hover:bg-transparent"
>
<span className="text-sm text-muted-foreground">
{__('Latest transactions in this account')}
</span>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${
isExistingOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-3 max-h-[250px] overflow-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('Date')}</TableHead>
<TableHead>
{__('Description')}
</TableHead>
<TableHead className="text-right">
{__('Amount')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{existingTransactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell className="whitespace-nowrap">
{formatDateMedium(
tx.transaction_date,
locale,
)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
<TransactionDescription
text={tx.description}
/>
</TableCell>
<TableCell className="text-right font-mono">
<AmountDisplay
amountInCents={tx.amount}
currencyCode={
tx.currency_code
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CollapsibleContent>
</Collapsible>
)}
<div className="flex justify-between">
<Button
variant="outline"
onClick={onBack}
disabled={isImporting}
>
{__('Back')}
</Button>
<Button
onClick={onConfirm}
disabled={isImporting || stats.selectedCount === 0}
>
{isImporting
? 'Importing...'
: `Import ${stats.selectedCount} Transaction${stats.selectedCount !== 1 ? 's' : ''}`}
</Button>
</div>
</div>
);
}