interacmanagernew / packages /web /src /components /dashboard /TransactionTable.tsx
Heaven K
fix: restore envelope number in export, remove accents from branches
7b00df2
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 <ArrowUpDown className="h-3 w-3 ml-1 opacity-0 group-hover/sort:opacity-50" />;
return filters.sortOrder === 'asc'
? <ArrowUp className="h-3 w-3 ml-1 text-primary" />
: <ArrowDown className="h-3 w-3 ml-1 text-primary" />;
};
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<HTMLInputElement>) => {
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 (
<div className="flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-border p-6 bg-white">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h3 className="text-lg font-bold text-slate-900">
{t('transactions.title')}
</h3>
<span className="inline-flex items-center rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-bold text-green-700 border border-green-200">
Live
</span>
</div>
<p className="text-sm text-slate-500">{t('transactions.subtitle')}</p>
</div>
<div className="flex items-center gap-4">
{/* Force Rescan toggle */}
<div className="flex items-center gap-3 px-3 py-2 bg-slate-50 rounded-lg border border-border/50">
<span className="text-xs font-semibold text-slate-600">Force Rescan</span>
<button
type="button"
onClick={() => 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'
)}
>
<span
className={cn(
'inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
forceRescan ? 'translate-x-4' : 'translate-x-1'
)}
/>
</button>
</div>
<div className="h-8 w-px bg-slate-100" />
{/* Search */}
<div className="relative hidden sm:block">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-[18px] w-[18px] text-slate-400 pointer-events-none" />
<Input
className="h-9 w-64 bg-white pl-9 text-sm placeholder-slate-400 shadow-sm"
placeholder={t('header.searchPlaceholder')}
value={searchInput}
onChange={handleSearch}
/>
</div>
<Button
variant={activeFilterCount > 0 ? 'default' : 'outline'}
size="sm"
className="gap-2 text-sm font-medium"
onClick={() => setShowFilters(!showFilters)}
>
<Filter className="h-[18px] w-[18px]" />
{t('transactions.filters')}
{activeFilterCount > 0 && (
<span className="ml-1 inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-bold">
{activeFilterCount}
</span>
)}
</Button>
<Button variant="outline" size="sm" className="gap-2 text-sm font-medium" onClick={handleExport} disabled={exporting}>
{exporting ? <Loader2 className="h-[18px] w-[18px] animate-spin" /> : <Download className="h-[18px] w-[18px]" />}
{t('transactions.export')}
</Button>
</div>
</div>
{/* Filter Panel */}
{showFilters && (
<div className="border-b border-border bg-slate-50/80 px-6 py-4">
<div className="flex flex-wrap items-end gap-4">
{/* Branch filter */}
<div className="flex flex-col gap-1.5 min-w-[180px]">
<label className="text-[11px] font-semibold uppercase tracking-wider text-slate-500">
{t('transactions.filterByBranch')}
</label>
<select
value={filters.branch}
onChange={(e) => 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"
>
<option value="">{t('transactions.allBranches')}</option>
{branchNames.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
{/* Status filter */}
<div className="flex flex-col gap-1.5 min-w-[160px]">
<label className="text-[11px] font-semibold uppercase tracking-wider text-slate-500">
{t('transactions.filterByStatus')}
</label>
<select
value={filters.status}
onChange={(e) => 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"
>
<option value="">{t('transactions.allStatuses')}</option>
<option value="deposited">{t('status.deposited')}</option>
<option value="pending">{t('status.pending')}</option>
<option value="expired">{t('status.expired')}</option>
<option value="cancelled">{t('status.cancelled')}</option>
</select>
</div>
{/* Reviewed filter */}
<div className="flex flex-col gap-1.5 min-w-[150px]">
<label className="text-[11px] font-semibold uppercase tracking-wider text-slate-500">
{t('transactions.filterByReviewed')}
</label>
<select
value={filters.reviewed}
onChange={(e) => 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"
>
<option value="">{t('transactions.allReviewed')}</option>
<option value="true">{t('transactions.reviewedOnly')}</option>
<option value="false">{t('transactions.notReviewedOnly')}</option>
</select>
</div>
{/* Date from */}
<div className="flex flex-col gap-1.5 min-w-[150px]">
<label className="text-[11px] font-semibold uppercase tracking-wider text-slate-500">
{t('transactions.filterDateFrom')}
</label>
<Input
type="date"
value={filters.from}
onChange={(e) => setFilter('from', e.target.value)}
className="h-9 bg-white text-sm shadow-sm"
/>
</div>
{/* Date to */}
<div className="flex flex-col gap-1.5 min-w-[150px]">
<label className="text-[11px] font-semibold uppercase tracking-wider text-slate-500">
{t('transactions.filterDateTo')}
</label>
<Input
type="date"
value={filters.to}
onChange={(e) => setFilter('to', e.target.value)}
className="h-9 bg-white text-sm shadow-sm"
/>
</div>
{/* Clear filters */}
{activeFilterCount > 0 && (
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 self-end"
onClick={handleClearFilters}
>
<X className="h-3.5 w-3.5" />
{t('transactions.clearFilters')}
</Button>
)}
</div>
</div>
)}
{/* Table */}
<div className="w-full">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow className="bg-slate-50/50 border-b border-border hover:bg-slate-50/50">
<TableHead
className="w-[12%] px-3 py-4 font-semibold text-xs uppercase tracking-wider text-slate-500 cursor-pointer select-none group/sort"
onClick={() => handleSort('date')}
>
<span className="flex items-center">{t('table.date')}<SortIcon column="date" /></span>
</TableHead>
<TableHead
className="w-[14%] px-3 py-4 font-semibold text-xs uppercase tracking-wider text-slate-500 cursor-pointer select-none group/sort"
onClick={() => handleSort('sender')}
>
<span className="flex items-center">{t('table.sender')}<SortIcon column="sender" /></span>
</TableHead>
<TableHead
className="w-[8%] px-3 py-4 font-semibold text-xs uppercase tracking-wider text-slate-500 cursor-pointer select-none group/sort"
onClick={() => handleSort('envelopeNumber')}
>
<span className="flex items-center">{t('table.envelopeNumber')}<SortIcon column="envelopeNumber" /></span>
</TableHead>
<TableHead
className="w-[9%] px-3 py-4 font-semibold text-xs uppercase tracking-wider text-slate-500 cursor-pointer select-none group/sort"
onClick={() => handleSort('amount')}
>
<span className="flex items-center">{t('table.amount')}<SortIcon column="amount" /></span>
</TableHead>
<TableHead
className="w-[12%] px-3 py-4 font-semibold text-xs uppercase tracking-wider text-slate-500 cursor-pointer select-none group/sort"
onClick={() => handleSort('reference')}
>
<span className="flex items-center">{t('table.reference')}<SortIcon column="reference" /></span>
</TableHead>
<TableHead
className="w-[14%] px-3 py-4 font-semibold text-xs uppercase tracking-wider text-slate-500 cursor-pointer select-none group/sort"
onClick={() => handleSort('branch')}
>
<span className="flex items-center">{t('table.branch')}<SortIcon column="branch" /></span>
</TableHead>
<TableHead className="w-[7%] px-3 py-4 font-semibold text-xs uppercase tracking-wider text-slate-500 text-center">
{t('table.accuracy')}
</TableHead>
<TableHead
className="w-[9%] px-3 py-4 font-semibold text-xs uppercase tracking-wider text-slate-500 cursor-pointer select-none group/sort"
onClick={() => handleSort('reviewed')}
>
<span className="flex items-center">{t('table.status')}<SortIcon column="reviewed" /></span>
</TableHead>
<TableHead className="w-[6%] px-3 py-4 font-semibold text-xs uppercase tracking-wider text-slate-500 text-right">
Action
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i} className="animate-pulse">
{Array.from({ length: 9 }).map((_, j) => (
<TableCell key={j} className="px-3 py-4">
<div className="h-4 bg-slate-100 rounded w-20" />
</TableCell>
))}
</TableRow>
))
) : transactions.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="px-3 py-12 text-center text-slate-500">
{t('transactions.noResults')}
</TableCell>
</TableRow>
) : (
transactions.map((tx: any) => {
const { date, time } = formatDate(tx.date);
const { percent: accuracy } = getMatchAccuracy(tx.amount, tx.rawEmail);
return (
<TableRow
key={tx.id}
className="group hover:bg-slate-50 transition-colors"
>
<TableCell className="px-3 py-4">
<div className="text-slate-900 font-medium text-sm">{date}</div>
<div className="text-slate-500 text-xs">{time}</div>
</TableCell>
<TableCell className="px-3 py-4 font-semibold text-slate-900 truncate" title={tx.sender}>
{tx.sender}
</TableCell>
<TableCell className="px-3 py-4">
{tx.envelopeNumber ? (
<span className="text-slate-700 font-mono text-xs bg-blue-50 text-blue-700 rounded px-1.5 py-0.5 font-bold">
#{tx.envelopeNumber}
</span>
) : (
<span className="text-slate-300 text-xs"></span>
)}
</TableCell>
<TableCell className="px-3 py-4 font-mono font-medium text-slate-700 whitespace-nowrap">
{formatAmount(tx.amount)}
</TableCell>
<TableCell className="px-3 py-4">
<span className="text-slate-600 font-mono text-xs bg-slate-100 rounded px-1.5 py-0.5 truncate block max-w-full" title={tx.reference || '—'}>
{tx.reference || '—'}
</span>
</TableCell>
<TableCell className="px-3 py-4 text-slate-600 truncate" title={tx.branch || 'Montreal'}>
<div className="flex items-center gap-2 min-w-0">
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', getBranchColor(tx.branch || ''))} />
<span className="truncate">{tx.branch || 'Montreal'}</span>
</div>
</TableCell>
<TableCell className="px-3 py-4 text-center">
{accuracy === -1 ? (
<span className="text-slate-300 text-xs"></span>
) : (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-bold',
accuracy === 100
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
: accuracy >= 80
? 'bg-amber-50 text-amber-700 ring-1 ring-amber-200'
: 'bg-red-50 text-red-700 ring-1 ring-red-200'
)}
>
{accuracy}%
</span>
)}
</TableCell>
<TableCell className="px-3 py-4">
<StatusBadge reviewed={tx.reviewed} />
</TableCell>
<TableCell className="px-3 py-4 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="text-slate-400 hover:text-slate-600 hover:bg-slate-100 p-1 rounded-md transition-all">
<MoreHorizontal className="h-5 w-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => navigate(`/transactions/${tx.id}/review`)}>
{t('transactions.viewDetails')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => window.open(`/api/receipts/${tx.id}/pdf`, '_blank')}>
{t('transactions.downloadReceipt')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between border-t border-border px-6 py-4 bg-white">
<div className="text-sm text-slate-500">
{t('transactions.showing')}{' '}
<span className="font-semibold text-slate-900">{((page - 1) * (data?.limit ?? 500)) + 1}</span>{' '}
{t('transactions.to')}{' '}
<span className="font-semibold text-slate-900">{Math.min(page * (data?.limit ?? 500), total)}</span>{' '}
{t('transactions.of')}{' '}
<span className="font-semibold text-slate-900">{total.toLocaleString('fr-CA')}</span>{' '}
{t('transactions.results')}
</div>
<div className="flex gap-2">
<button
disabled={page <= 1}
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"
>
<ChevronLeft className="h-4 w-4" />
</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const pageNum = i + 1;
return (
<button
key={pageNum}
onClick={() => 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}
</button>
);
})}
<button
disabled={page >= 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"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}