Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |