initial commit: rocket.new export of broadcastbeat
This commit is contained in:
116
src/app/admin/accounting/reconciliation/page.tsx
Normal file
116
src/app/admin/accounting/reconciliation/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
function fmt(cents: number) { return '$' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2 }); }
|
||||
|
||||
export default function ReconciliationPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [transactions, setTransactions] = useState<any[]>([]);
|
||||
const [loadingData, setLoadingData] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [summary, setSummary] = useState<{ matched: number; unmatched: number } | null>(null);
|
||||
|
||||
useEffect(() => { if (!loading && !user) router.push('/login'); }, [user, loading, router]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
const r = await fetch('/api/accounting/reconciliation');
|
||||
const d = await r.json();
|
||||
setTransactions(d.transactions ?? []);
|
||||
setSummary(d.summary ?? null);
|
||||
setLoadingData(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (user) load(); }, [user, load]);
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
const text = await file.text();
|
||||
const r = await fetch('/api/accounting/reconciliation', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csv: text }),
|
||||
});
|
||||
const d = await r.json();
|
||||
setSummary(d.summary ?? null);
|
||||
setUploading(false);
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleManualMatch(txId: string, invoiceId: string) {
|
||||
await fetch('/api/accounting/reconciliation/match', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transaction_id: txId, invoice_id: invoiceId }),
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] text-white p-6">
|
||||
<div className="max-w-6xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Bank Reconciliation</h1>
|
||||
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
||||
</div>
|
||||
<label className={`px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 cursor-pointer ${uploading ? 'opacity-50' : ''}`}>
|
||||
{uploading ? 'Uploading…' : 'Upload Bank CSV'}
|
||||
<input type="file" accept=".csv" className="hidden" onChange={handleUpload} disabled={uploading} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-green-400/10 border border-green-400/20 rounded-lg p-4">
|
||||
<p className="text-2xl font-bold text-green-400">{summary.matched}</p>
|
||||
<p className="text-xs text-[#888] mt-1">Auto-matched transactions</p>
|
||||
</div>
|
||||
<div className="bg-yellow-400/10 border border-yellow-400/20 rounded-lg p-4">
|
||||
<p className="text-2xl font-bold text-yellow-400">{summary.unmatched}</p>
|
||||
<p className="text-xs text-[#888] mt-1">Need review</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1a1a1a]">
|
||||
{['Date','Description','Amount','Type','Matched To','Reconciled','Action'].map(h => (
|
||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loadingData ? (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">Loading…</td></tr>
|
||||
) : transactions.length === 0 ? (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No transactions. Upload a CSV to begin.</td></tr>
|
||||
) : transactions.map((tx: any) => (
|
||||
<tr key={tx.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
||||
<td className="px-3 py-2 text-[#888] text-xs">{tx.transaction_date}</td>
|
||||
<td className="px-3 py-2 text-white text-xs max-w-xs truncate">{tx.description}</td>
|
||||
<td className={`px-3 py-2 text-xs font-medium ${tx.type === 'credit' ? 'text-green-400' : 'text-red-400'}`}>{fmt(Math.abs(tx.amount_cents ?? 0))}</td>
|
||||
<td className="px-3 py-2 text-[#888] text-xs capitalize">{tx.type}</td>
|
||||
<td className="px-3 py-2 text-[#555] text-xs">{tx.matched_invoice_id ? `Invoice ${tx.matched_invoice_id.slice(0, 8)}…` : tx.matched_expense_id ? `Expense` : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${tx.reconciled ? 'bg-green-400/10 text-green-400' : 'bg-yellow-400/10 text-yellow-400'}`}>{tx.reconciled ? 'Yes' : 'No'}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{!tx.reconciled && <span className="text-xs text-[#555]">Manual match</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user