'use client'; import React, { useState, useEffect } from 'react'; import Link from 'next/link'; import { formatCurrency } from '@/lib/utils'; import { ErrorState } from '@/components/ui/ErrorState'; import { ArrowLeft, CheckCircle2, AlertTriangle, Loader2, RefreshCcw, ArrowRightLeft, Search, Check, Zap, Download, Flag, } from 'lucide-react'; import { apiFetch, apiJson, ApiError } from '@/lib/api-client'; import { toast } from '@/lib/toast'; import { confirmDialog } from '@/lib/confirm'; interface BankTransaction { id: string; bankAccountId: string; bankAccountName: string; bankAccountMasked: string; transactionDate: string; description: string; amount: number; transactionType: string; referenceNumber: string | null; oppositeParty: string | null; reconciled: boolean; reconciledAt: string | null; status: string; } interface BankAccount { id: string; accountName: string; bankName: string; currentBalance: number; accountNumberMasked: string; } export default function ReconciliationPage() { const [accounts, setAccounts] = useState([]); const [transactions, setTransactions] = useState([]); const [selectedAccount, setSelectedAccount] = useState('all'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(''); const [selectedIds, setSelectedIds] = useState>(new Set()); const [reconciling, setReconciling] = useState(false); const [showReconciled, setShowReconciled] = useState(false); const [autoMatching, setAutoMatching] = useState(false); const [completing, setCompleting] = useState(false); useEffect(() => { fetchData(); }, []); const fetchData = async () => { setLoading(true); setError(null); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); try { const [acctsData, txnsData] = await Promise.all([ apiJson('/api/bank-accounts', { signal: controller.signal, cache: 'no-store', silent: true }), apiJson('/api/bank-transactions', { signal: controller.signal, cache: 'no-store', silent: true }), ]); setAccounts(Array.isArray(acctsData) ? acctsData : acctsData.accounts || []); setTransactions(Array.isArray(txnsData) ? txnsData : txnsData.transactions || []); } catch (err: any) { const msg = err?.name === 'AbortError' ? 'Request timed out after 30s' : err instanceof ApiError ? err.message : err?.message || 'Failed to fetch reconciliation data'; console.error('[Reconciliation] fetch error:', err); setError(msg); } finally { clearTimeout(timeoutId); setLoading(false); } }; const filteredTransactions = transactions.filter((txn) => { if (selectedAccount !== 'all' && txn.bankAccountId !== selectedAccount) return false; if (!showReconciled && txn.reconciled) return false; if (search.trim()) { const q = search.toLowerCase(); return ( txn.description?.toLowerCase().includes(q) || txn.referenceNumber?.toLowerCase().includes(q) || String(txn.amount).includes(q) ); } return true; }); const unreconciledCount = transactions.filter((t) => !t.reconciled).length; const reconciledCount = transactions.filter((t) => t.reconciled).length; const selectedTotal = filteredTransactions .filter((t) => selectedIds.has(t.id)) .reduce((sum, t) => sum + Number(t.amount), 0); const toggleSelect = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const selectAll = () => { const unreconciled = filteredTransactions.filter((t) => !t.reconciled); if (selectedIds.size === unreconciled.length) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(unreconciled.map((t) => t.id))); } }; const handleReconcile = async () => { if (selectedIds.size === 0) return; setReconciling(true); try { // Mark selected transactions as reconciled await apiFetch('/api/bank-transactions/reconcile', { method: 'POST', body: JSON.stringify({ transactionIds: Array.from(selectedIds) }), silent: true, }); // Update local state setTransactions((prev) => prev.map((t) => selectedIds.has(t.id) ? { ...t, reconciled: true, reconciledAt: new Date().toISOString() } : t ) ); setSelectedIds(new Set()); } catch (err) { toast.error('Failed to reconcile transactions. Please try again.'); } finally { setReconciling(false); } }; const handleAutoMatch = async () => { if (transactions.length === 0) return; setAutoMatching(true); try { const data = await apiJson('/api/bank-transactions/auto-match', { method: 'POST', body: JSON.stringify({ bankAccountId: selectedAccount !== 'all' ? selectedAccount : undefined, }), silent: true, }); const matchedIds = new Set((data.matches || []).map((m: any) => m.txnId)); if (matchedIds.size > 0) { setTransactions((prev) => prev.map((t) => matchedIds.has(t.id) ? { ...t, reconciled: true, reconciledAt: new Date().toISOString() } : t ) ); toast.success(`Auto-matched ${matchedIds.size} transaction(s) against payments and journal entries`); } else { toast.success('No matches found. Try widening the date window or importing more payments.'); } } catch (err: any) { toast.error(err instanceof ApiError ? err.message : (err?.message || 'Failed to auto-match. Please try again.')); } finally { setAutoMatching(false); } }; const handleExportReconciliation = () => { try { const csv = generateReconciliationCSV(); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); const url = URL.createObjectURL(blob); link.setAttribute('href', url); link.setAttribute('download', `reconciliation-${new Date().toISOString().split('T')[0]}.csv`); link.click(); } catch (err) { toast.error('Failed to export reconciliation'); } }; const generateReconciliationCSV = () => { const lines = [ ['Bank Reconciliation Report'], [`Generated: ${new Date().toLocaleString()}`], [''], ['Date', 'Account', 'Description', 'Amount', 'Status', 'Reconciled At'], ...transactions.map((t) => [ new Date(t.transactionDate).toLocaleDateString(), t.bankAccountName, t.description, t.amount.toString(), t.reconciled ? 'Reconciled' : 'Unreconciled', t.reconciledAt ? new Date(t.reconciledAt).toLocaleString() : '—', ]), [''], ['Summary'], ['Total Unreconciled', transactions.filter((t) => !t.reconciled).length.toString()], ['Total Reconciled', transactions.filter((t) => t.reconciled).length.toString()], ['Reconciliation Rate', `${Math.round((transactions.filter((t) => t.reconciled).length / transactions.length) * 100)}%`], ]; return lines.map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n'); }; const handleCompleteReconciliation = async () => { const unreconciledCount = transactions.filter((t) => !t.reconciled).length; if (unreconciledCount > 0) { if (!await confirmDialog(`${unreconciledCount} transaction(s) are still unreconciled. Complete anyway?`)) { return; } } if (selectedAccount === 'all') { toast.error('Select a specific bank account before completing a reconciliation period.'); return; } const stmtRaw = window.prompt('Enter the ending statement balance from your bank statement:', '0.00'); if (stmtRaw === null) return; const stmtBalance = parseFloat(stmtRaw); if (Number.isNaN(stmtBalance)) { toast.error('Invalid statement balance'); return; } setCompleting(true); try { try { await apiFetch('/api/reconciliation/complete', { method: 'POST', body: JSON.stringify({ bankAccountId: selectedAccount, statementBalance: stmtBalance, }), silent: true, }); } catch (err) { if (err instanceof ApiError) { const body = err.body as { difference?: number; error?: string } | null; // If out-of-balance, let the user retry with force if (body && typeof body.difference === 'number') { if (await confirmDialog( `Reconciliation is out of balance by $${body.difference.toFixed(2)}. Complete anyway?` )) { await apiFetch('/api/reconciliation/complete', { method: 'POST', body: JSON.stringify({ bankAccountId: selectedAccount, statementBalance: stmtBalance, force: true, }), silent: true, }); } else { return; } } else { throw err; } } else { throw err; } } toast.success('Reconciliation period completed successfully'); fetchData(); } catch (err: any) { toast.error(err?.message || 'Failed to complete reconciliation. Please try again.'); } finally { setCompleting(false); } }; if (loading) { return (
); } if (error) { return ; } return (
{/* Header */}

Bank Reconciliation

Match bank transactions with GL entries

{/* Summary Cards */}

Total Transactions

{transactions.length}

Unreconciled

{unreconciledCount}

Reconciled

{reconciledCount}

Reconciliation Rate

{transactions.length > 0 ? Math.round((reconciledCount / transactions.length) * 100) : 0}%

{/* Filters & Actions */}
{/* Account filter */} {/* Search */}
setSearch(e.target.value)} className="bg-transparent text-sm outline-none w-full" />
{/* Show reconciled toggle */} {/* Reconcile button */} {selectedIds.size > 0 && ( )}
{/* Transaction Table */}
{filteredTransactions.length === 0 ? ( ) : ( filteredTransactions.map((txn) => ( )) )}
0 && selectedIds.size === filteredTransactions.filter((t) => !t.reconciled).length} onChange={selectAll} className="rounded border-[#3A3A45]" /> Date Description Account Reference Amount Status

{showReconciled ? 'No transactions found' : 'All transactions are reconciled!'}

{!txn.reconciled && ( toggleSelect(txn.id)} className="rounded border-[#3A3A45]" /> )} {new Date(txn.transactionDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', })} {txn.description} {txn.bankAccountName} {txn.referenceNumber || '—'} = 0 ? 'text-emerald-600' : 'text-red-600' }`}> {formatCurrency(Number(txn.amount))} {txn.reconciled ? ( Matched ) : ( Unmatched )}
); }