initial commit

This commit is contained in:
Josh Myers
2026-04-09 20:36:10 -07:00
commit 4681b1a3c8
248 changed files with 97032 additions and 0 deletions

View 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>
);
}