import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { Filter, Download, Search, MoreHorizontal, ChevronLeft, ChevronRight, ArrowUp, ArrowDown, ArrowUpDown, Loader2, X } from 'lucide-react'; import * as XLSX from 'xlsx'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import StatusBadge from './StatusBadge'; import { cn } from '@/lib/utils'; import { useTransactions } from '@/hooks/useTransactions'; import { useTransactionStore } from '@/stores/transactionStore'; import { api } from '@/services/api'; const BRANCH_COLORS = [ 'bg-blue-500', 'bg-purple-500', 'bg-orange-500', 'bg-teal-500', 'bg-indigo-500', 'bg-pink-500', 'bg-emerald-500', 'bg-amber-500', ]; function getBranchColor(branch: string): string { let hash = 0; for (let i = 0; i < branch.length; i++) { hash = branch.charCodeAt(i) + ((hash << 5) - hash); } return BRANCH_COLORS[Math.abs(hash) % BRANCH_COLORS.length]; } function formatAmount(amount: number): string { return new Intl.NumberFormat('fr-CA', { style: 'currency', currency: 'CAD', minimumFractionDigits: 2, }).format(amount); } /** Extract the dollar amount from the raw Interac email text */ function extractEmailAmount(rawEmail?: string | null): number | null { if (!rawEmail) return null; // Match patterns like "$40.00", "$215.00 (CAD)", "Amount: $50.00" const match = rawEmail.match(/\$\s*([0-9,]+(?:\.\d{1,2})?)/); if (match) { return parseFloat(match[1].replace(/,/g, '')); } return null; } /** Compute match accuracy between AI-parsed amount and raw email amount */ function getMatchAccuracy(aiAmount: number, rawEmail?: string | null): { percent: number; emailAmount: number | null } { const emailAmount = extractEmailAmount(rawEmail); if (emailAmount === null || emailAmount === 0) return { percent: -1, emailAmount: null }; if (aiAmount === emailAmount) return { percent: 100, emailAmount }; const diff = Math.abs(aiAmount - emailAmount); const percent = Math.max(0, Math.round((1 - diff / emailAmount) * 100)); return { percent, emailAmount }; } function formatDate(dateStr: string): { date: string; time: string } { try { const d = new Date(dateStr); return { date: d.toLocaleDateString('fr-CA', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'America/Toronto' }), time: d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Toronto' }), }; } catch { return { date: dateStr, time: '' }; } } export default function TransactionTable() { const { t } = useTranslation(); const navigate = useNavigate(); const { filters, setFilter, setFilters } = useTransactionStore(); const [searchInput, setSearchInput] = useState(filters.search); const [forceRescan, setForceRescan] = useState(false); const [exporting, setExporting] = useState(false); const [showFilters, setShowFilters] = useState(false); // Fetch branches for filter dropdown const { data: branchData } = useQuery<{ branches: { id: number; branch: string }[] }>({ queryKey: ['branches'], queryFn: () => api.get('/settings/branches'), }); const branchNames = [...new Set((branchData?.branches ?? []).map(b => b.branch))].sort(); const activeFilterCount = [filters.branch, filters.status, filters.reviewed, filters.from, filters.to].filter(Boolean).length; const handleClearFilters = useCallback(() => { setFilters({ branch: '', status: '', reviewed: '', from: '', to: '' }); }, [setFilters]); const handleSort = useCallback((column: string) => { if (filters.sortBy === column) { setFilters({ sortBy: column, sortOrder: filters.sortOrder === 'asc' ? 'desc' : 'asc' }); } else { setFilters({ sortBy: column, sortOrder: column === 'amount' ? 'desc' : 'asc' }); } }, [filters.sortBy, filters.sortOrder, setFilters]); const SortIcon = ({ column }: { column: string }) => { if (filters.sortBy !== column) return ; return filters.sortOrder === 'asc' ? : ; }; const { data, isLoading } = useTransactions(filters); const transactions = data?.transactions ?? []; const total = data?.total ?? 0; const totalPages = data?.totalPages ?? 1; const page = data?.page ?? 1; const handleSearch = useCallback((e: React.ChangeEvent) => { setSearchInput(e.target.value); // Debounce const timer = setTimeout(() => setFilter('search', e.target.value), 300); return () => clearTimeout(timer); }, [setFilter]); const handleExport = useCallback(async () => { setExporting(true); try { // Use fast export endpoint with current filters const params = new URLSearchParams(); if (filters.branch) params.set('branch', filters.branch); if (filters.from) params.set('from', filters.from); if (filters.to) params.set('to', filters.to); const result = await api.get<{ transactions: any[] }>(`/transactions/export?${params}`); const rows = result.transactions; if (rows.length === 0) return; // Build worksheet data const wsData = rows.map((tx: any) => ({ [t('table.date')]: tx.date ? new Date(tx.date).toLocaleDateString('fr-CA', { timeZone: 'America/Toronto' }) : '', [t('table.sender')]: tx.sender || '', [t('table.envelopeNumber')]: tx.envelopeNumber || '', [t('table.amount')]: tx.amount, 'Currency': tx.currency || 'CAD', [t('table.reference')]: tx.reference || '', [t('table.branch')]: tx.branch || 'Montreal', [t('table.status')]: tx.reviewed ? t('status.verified') : t('status.notVerified'), 'Email': tx.recipientEmail || '', 'Message': tx.message || '', })); const ws = XLSX.utils.json_to_sheet(wsData); // Fixed column widths for speed ws['!cols'] = [ { wch: 12 }, // Date { wch: 25 }, // Sender { wch: 16 }, // Envelope Number { wch: 12 }, // Amount { wch: 6 }, // Currency { wch: 20 }, // Reference { wch: 20 }, // Branch { wch: 12 }, // Status { wch: 35 }, // Email { wch: 30 }, // Message ]; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Transactions'); const dateStr = new Date().toISOString().split('T')[0]; XLSX.writeFile(wb, `ICC_Transactions_${dateStr}.xlsx`); } catch (err) { console.error('Export failed:', err); } finally { setExporting(false); } }, [filters, t]); return ( {/* Header */} {t('transactions.title')} Live {t('transactions.subtitle')} {/* Force Rescan toggle */} Force Rescan setForceRescan(!forceRescan)} className={cn( 'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2', forceRescan ? 'bg-primary' : 'bg-slate-200' )} > {/* Search */} 0 ? 'default' : 'outline'} size="sm" className="gap-2 text-sm font-medium" onClick={() => setShowFilters(!showFilters)} > {t('transactions.filters')} {activeFilterCount > 0 && ( {activeFilterCount} )} {exporting ? : } {t('transactions.export')} {/* Filter Panel */} {showFilters && ( {/* Branch filter */} {t('transactions.filterByBranch')} setFilter('branch', e.target.value)} className="h-9 rounded-md border border-border bg-white px-3 text-sm text-slate-700 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" > {t('transactions.allBranches')} {branchNames.map((name) => ( {name} ))} {/* Status filter */} {t('transactions.filterByStatus')} setFilter('status', e.target.value)} className="h-9 rounded-md border border-border bg-white px-3 text-sm text-slate-700 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" > {t('transactions.allStatuses')} {t('status.deposited')} {t('status.pending')} {t('status.expired')} {t('status.cancelled')} {/* Reviewed filter */} {t('transactions.filterByReviewed')} setFilter('reviewed', e.target.value)} className="h-9 rounded-md border border-border bg-white px-3 text-sm text-slate-700 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" > {t('transactions.allReviewed')} {t('transactions.reviewedOnly')} {t('transactions.notReviewedOnly')} {/* Date from */} {t('transactions.filterDateFrom')} setFilter('from', e.target.value)} className="h-9 bg-white text-sm shadow-sm" /> {/* Date to */} {t('transactions.filterDateTo')} setFilter('to', e.target.value)} className="h-9 bg-white text-sm shadow-sm" /> {/* Clear filters */} {activeFilterCount > 0 && ( {t('transactions.clearFilters')} )} )} {/* Table */} handleSort('date')} > {t('table.date')} handleSort('sender')} > {t('table.sender')} handleSort('envelopeNumber')} > {t('table.envelopeNumber')} handleSort('amount')} > {t('table.amount')} handleSort('reference')} > {t('table.reference')} handleSort('branch')} > {t('table.branch')} {t('table.accuracy')} handleSort('reviewed')} > {t('table.status')} Action {isLoading ? ( Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 9 }).map((_, j) => ( ))} )) ) : transactions.length === 0 ? ( {t('transactions.noResults')} ) : ( transactions.map((tx: any) => { const { date, time } = formatDate(tx.date); const { percent: accuracy } = getMatchAccuracy(tx.amount, tx.rawEmail); return ( {date} {time} {tx.sender} {tx.envelopeNumber ? ( #{tx.envelopeNumber} ) : ( — )} {formatAmount(tx.amount)} {tx.reference || '—'} {tx.branch || 'Montreal'} {accuracy === -1 ? ( — ) : ( = 80 ? 'bg-amber-50 text-amber-700 ring-1 ring-amber-200' : 'bg-red-50 text-red-700 ring-1 ring-red-200' )} > {accuracy}% )} navigate(`/transactions/${tx.id}/review`)}> {t('transactions.viewDetails')} window.open(`/api/receipts/${tx.id}/pdf`, '_blank')}> {t('transactions.downloadReceipt')} ); }) )} {/* Pagination */} {t('transactions.showing')}{' '} {((page - 1) * (data?.limit ?? 500)) + 1}{' '} {t('transactions.to')}{' '} {Math.min(page * (data?.limit ?? 500), total)}{' '} {t('transactions.of')}{' '} {total.toLocaleString('fr-CA')}{' '} {t('transactions.results')} setFilter('page', page - 1)} className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-white text-slate-500 hover:bg-slate-50 hover:text-slate-900 disabled:opacity-50 transition-colors shadow-sm" > {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { const pageNum = i + 1; return ( setFilter('page', pageNum)} className={cn( 'flex h-9 w-9 items-center justify-center rounded-lg text-sm font-semibold transition-colors shadow-sm', pageNum === page ? 'bg-primary text-white shadow-md shadow-primary/20' : 'border border-border bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-900' )} > {pageNum} ); })} = totalPages} onClick={() => setFilter('page', page + 1)} className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-white text-slate-500 hover:bg-slate-50 hover:text-slate-900 disabled:opacity-50 transition-colors shadow-sm" > ); }
{t('transactions.subtitle')}