initial commit
This commit is contained in:
523
src/app/(dashboard)/finance/treasury/reconciliation/page.tsx
Normal file
523
src/app/(dashboard)/finance/treasury/reconciliation/page.tsx
Normal file
@@ -0,0 +1,523 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user