524 lines
20 KiB
TypeScript
524 lines
20 KiB
TypeScript
'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<BankAccount[]>([]);
|
|
const [transactions, setTransactions] = useState<BankTransaction[]>([]);
|
|
const [selectedAccount, setSelectedAccount] = useState<string>('all');
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [search, setSearch] = useState('');
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<any>('/api/bank-accounts', { signal: controller.signal, cache: 'no-store', silent: true }),
|
|
apiJson<any>('/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<any>('/api/bank-transactions/auto-match', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
bankAccountId: selectedAccount !== 'all' ? selectedAccount : undefined,
|
|
}),
|
|
silent: true,
|
|
});
|
|
const matchedIds = new Set<string>((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 (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<Loader2 size={32} className="text-blue-500 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return <ErrorState message={error} title="Couldn't load reconciliation" onRetry={fetchData} />;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Link href="/finance/treasury" className="p-2 hover:bg-[#222228] rounded-lg">
|
|
<ArrowLeft size={20} className="text-[#8B8B9E]" />
|
|
</Link>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-[#F0F0F3] flex items-center gap-2">
|
|
<ArrowRightLeft size={24} className="text-blue-600" />
|
|
Bank Reconciliation
|
|
</h1>
|
|
<p className="text-sm text-[#5A5A6E] mt-0.5">Match bank transactions with GL entries</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleAutoMatch}
|
|
disabled={autoMatching || transactions.filter((t) => !t.reconciled).length === 0}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Zap size={14} />
|
|
{autoMatching ? 'Matching...' : 'Auto-Match'}
|
|
</button>
|
|
<button
|
|
onClick={handleExportReconciliation}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm border border-[#2A2A32] rounded-lg hover:bg-[#222228]"
|
|
>
|
|
<Download size={14} />
|
|
Export
|
|
</button>
|
|
<button
|
|
onClick={handleCompleteReconciliation}
|
|
disabled={completing}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50"
|
|
>
|
|
<Flag size={14} />
|
|
{completing ? 'Completing...' : 'Complete Period'}
|
|
</button>
|
|
<button
|
|
onClick={fetchData}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm border border-[#2A2A32] rounded-lg hover:bg-[#222228]"
|
|
>
|
|
<RefreshCcw size={14} />
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div className="bg-[#1A1A1F] rounded-xl p-4 border border-[#2A2A32]">
|
|
<p className="text-xs font-medium text-[#5A5A6E]">Total Transactions</p>
|
|
<p className="text-2xl font-bold text-[#F0F0F3] mt-1">{transactions.length}</p>
|
|
</div>
|
|
<div className="bg-amber-900/20 rounded-xl p-4 border border-amber-800/40">
|
|
<p className="text-xs font-medium text-amber-400">Unreconciled</p>
|
|
<p className="text-2xl font-bold text-amber-300 mt-1">{unreconciledCount}</p>
|
|
</div>
|
|
<div className="bg-emerald-900/20 rounded-xl p-4 border border-emerald-800/40">
|
|
<p className="text-xs font-medium text-emerald-400">Reconciled</p>
|
|
<p className="text-2xl font-bold text-emerald-300 mt-1">{reconciledCount}</p>
|
|
</div>
|
|
<div className="bg-blue-900/20 rounded-xl p-4 border border-blue-800/40">
|
|
<p className="text-xs font-medium text-blue-400">Reconciliation Rate</p>
|
|
<p className="text-2xl font-bold text-blue-300 mt-1">
|
|
{transactions.length > 0 ? Math.round((reconciledCount / transactions.length) * 100) : 0}%
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters & Actions */}
|
|
<div className="bg-[#1A1A1F] rounded-xl border border-[#2A2A32] p-4">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{/* Account filter */}
|
|
<select
|
|
value={selectedAccount}
|
|
onChange={(e) => setSelectedAccount(e.target.value)}
|
|
className="text-sm border border-[#2A2A32] rounded-lg px-3 py-2 bg-[#111114] outline-none focus:ring-2 focus:ring-indigo-500 text-[#F0F0F3]"
|
|
>
|
|
<option value="all">All Accounts</option>
|
|
{accounts.map((acct) => (
|
|
<option key={acct.id} value={acct.id}>
|
|
{acct.bankName} - {acct.accountName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Search */}
|
|
<div className="flex items-center gap-2 bg-[#222228] rounded-lg px-3 py-2 flex-1 min-w-[200px]">
|
|
<Search size={16} className="text-[#5A5A6E]" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search transactions..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="bg-transparent text-sm outline-none w-full"
|
|
/>
|
|
</div>
|
|
|
|
{/* Show reconciled toggle */}
|
|
<label className="flex items-center gap-2 text-sm text-[#8B8B9E] cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={showReconciled}
|
|
onChange={(e) => setShowReconciled(e.target.checked)}
|
|
className="rounded border-[#3A3A45]"
|
|
/>
|
|
Show reconciled
|
|
</label>
|
|
|
|
{/* Reconcile button */}
|
|
{selectedIds.size > 0 && (
|
|
<button
|
|
onClick={handleReconcile}
|
|
disabled={reconciling}
|
|
className="ml-auto flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 disabled:opacity-50 font-medium"
|
|
>
|
|
{reconciling ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<Check size={14} />
|
|
)}
|
|
Reconcile {selectedIds.size} transaction{selectedIds.size > 1 ? 's' : ''}
|
|
<span className="text-emerald-200">({formatCurrency(selectedTotal)})</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Transaction Table */}
|
|
<div className="bg-[#1A1A1F] rounded-xl border border-[#2A2A32] overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-[#111114] border-b border-[#2A2A32]">
|
|
<th className="w-10 px-4 py-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.size > 0 && selectedIds.size === filteredTransactions.filter((t) => !t.reconciled).length}
|
|
onChange={selectAll}
|
|
className="rounded border-[#3A3A45]"
|
|
/>
|
|
</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-[#5A5A6E] uppercase tracking-wider">Date</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-[#5A5A6E] uppercase tracking-wider">Description</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-[#5A5A6E] uppercase tracking-wider">Account</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-[#5A5A6E] uppercase tracking-wider">Reference</th>
|
|
<th className="text-right px-4 py-3 text-xs font-semibold text-[#5A5A6E] uppercase tracking-wider">Amount</th>
|
|
<th className="text-center px-4 py-3 text-xs font-semibold text-[#5A5A6E] uppercase tracking-wider">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[#2A2A32]">
|
|
{filteredTransactions.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-12 text-center">
|
|
<CheckCircle2 size={32} className="text-emerald-400 mx-auto mb-2" />
|
|
<p className="text-sm text-[#5A5A6E]">
|
|
{showReconciled ? 'No transactions found' : 'All transactions are reconciled!'}
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredTransactions.map((txn) => (
|
|
<tr
|
|
key={txn.id}
|
|
className={`hover:bg-[#222228] transition-colors ${
|
|
selectedIds.has(txn.id) ? 'bg-[#222228]' : ''
|
|
}`}
|
|
>
|
|
<td className="px-4 py-3">
|
|
{!txn.reconciled && (
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.has(txn.id)}
|
|
onChange={() => toggleSelect(txn.id)}
|
|
className="rounded border-[#3A3A45]"
|
|
/>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-[#8B8B9E] whitespace-nowrap">
|
|
{new Date(txn.transactionDate).toLocaleDateString('en-US', {
|
|
month: 'short', day: 'numeric',
|
|
})}
|
|
</td>
|
|
<td className="px-4 py-3 text-[#F0F0F3] font-medium max-w-xs truncate">
|
|
{txn.description}
|
|
</td>
|
|
<td className="px-4 py-3 text-[#8B8B9E] text-xs whitespace-nowrap">
|
|
{txn.bankAccountName}
|
|
</td>
|
|
<td className="px-4 py-3 text-[#5A5A6E] text-xs font-mono">
|
|
{txn.referenceNumber || '—'}
|
|
</td>
|
|
<td className={`px-4 py-3 text-right font-semibold whitespace-nowrap ${
|
|
Number(txn.amount) >= 0 ? 'text-emerald-600' : 'text-red-600'
|
|
}`}>
|
|
{formatCurrency(Number(txn.amount))}
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
{txn.reconciled ? (
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-400 bg-emerald-900/25 px-2 py-0.5 rounded-full border border-emerald-700/50">
|
|
<CheckCircle2 size={12} /> Matched
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-400 bg-amber-900/25 px-2 py-0.5 rounded-full border border-amber-700/50">
|
|
<AlertTriangle size={12} /> Unmatched
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|