196 lines
7.8 KiB
TypeScript
196 lines
7.8 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
|
||
interface Props {
|
||
companyId?: string;
|
||
selectedCompany: 'rmp' | 'jtr';
|
||
companies: any[];
|
||
}
|
||
|
||
function fmt(n: number) {
|
||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
|
||
}
|
||
|
||
export default function ReportsTab({ companyId, selectedCompany, companies }: Props) {
|
||
const [invoices, setInvoices] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [reportType, setReportType] = useState<'monthly' | 'quarterly' | 'yearly'>('monthly');
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
const params = companyId ? `?companyId=${companyId}` : '';
|
||
fetch(`/api/billing/invoices${params}`)
|
||
.then(r => r.json())
|
||
.then(d => setInvoices(d.data || []))
|
||
.finally(() => setLoading(false));
|
||
}, [companyId]);
|
||
|
||
// AR Aging
|
||
const today = new Date();
|
||
const aging = {
|
||
current: [] as any[],
|
||
'1-30': [] as any[],
|
||
'31-60': [] as any[],
|
||
'61-90': [] as any[],
|
||
'90+': [] as any[],
|
||
};
|
||
|
||
invoices
|
||
.filter(inv => !['paid', 'cancelled', 'draft'].includes(inv.status))
|
||
.forEach(inv => {
|
||
if (!inv.due_date) return;
|
||
const due = new Date(inv.due_date);
|
||
const days = Math.floor((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24));
|
||
if (days <= 0) aging.current.push(inv);
|
||
else if (days <= 30) aging['1-30'].push(inv);
|
||
else if (days <= 60) aging['31-60'].push(inv);
|
||
else if (days <= 90) aging['61-90'].push(inv);
|
||
else aging['90+'].push(inv);
|
||
});
|
||
|
||
// Top clients
|
||
const clientTotals: Record<string, { name: string; total: number }> = {};
|
||
invoices
|
||
.filter(inv => inv.status === 'paid')
|
||
.forEach(inv => {
|
||
const name = inv.billing_clients?.name || 'Unknown';
|
||
if (!clientTotals[name]) clientTotals[name] = { name, total: 0 };
|
||
clientTotals[name].total += Number(inv.total || 0);
|
||
});
|
||
const topClients = Object.values(clientTotals).sort((a, b) => b.total - a.total).slice(0, 10);
|
||
|
||
// Revenue by period
|
||
const now = new Date();
|
||
const currentYear = now.getFullYear();
|
||
const yearlyRevenue = invoices
|
||
.filter(inv => inv.status === 'paid' && inv.paid_at?.startsWith(String(currentYear)))
|
||
.reduce((s, inv) => s + Number(inv.total || 0), 0);
|
||
|
||
const totalInvoiced = invoices.reduce((s, inv) => s + Number(inv.total || 0), 0);
|
||
|
||
const handleExportCSV = () => {
|
||
const rows = [
|
||
['Invoice #', 'Client', 'Amount', 'Status', 'Issue Date', 'Due Date', 'Paid At'],
|
||
...invoices.map(inv => [
|
||
inv.invoice_number,
|
||
inv.billing_clients?.name || '',
|
||
inv.total,
|
||
inv.status,
|
||
inv.issue_date || '',
|
||
inv.due_date || '',
|
||
inv.paid_at || '',
|
||
]),
|
||
];
|
||
const csv = rows.map(r => r.join(',')).join('\n');
|
||
const blob = new Blob([csv], { type: 'text/csv' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `billing-report-${selectedCompany}-${new Date().toISOString().split('T')[0]}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex gap-2">
|
||
{(['monthly', 'quarterly', 'yearly'] as const).map(t => (
|
||
<button
|
||
key={t}
|
||
onClick={() => setReportType(t)}
|
||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||
reportType === t ? 'bg-blue-600 text-white' : 'bg-gray-800 text-gray-400 hover:text-white'
|
||
}`}
|
||
>
|
||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={handleExportCSV}
|
||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||
</svg>
|
||
Export CSV
|
||
</button>
|
||
</div>
|
||
|
||
{/* Summary */}
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||
<p className="text-xs text-gray-400 mb-1">Total Invoiced ({currentYear})</p>
|
||
<p className="text-2xl font-bold text-white">{fmt(yearlyRevenue)}</p>
|
||
</div>
|
||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||
<p className="text-xs text-gray-400 mb-1">Total Invoiced (All Time)</p>
|
||
<p className="text-2xl font-bold text-white">{fmt(totalInvoiced)}</p>
|
||
</div>
|
||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||
<p className="text-xs text-gray-400 mb-1">Tax Summary ({currentYear})</p>
|
||
<p className="text-2xl font-bold text-white">{fmt(yearlyRevenue)}</p>
|
||
<p className="text-xs text-gray-500 mt-1">Total invoiced — for accounting export</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AR Aging */}
|
||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
||
<h3 className="text-sm font-semibold text-gray-300 mb-4">Accounts Receivable Aging</h3>
|
||
<div className="space-y-3">
|
||
{[
|
||
{ label: 'Current (not yet due)', key: 'current', color: 'text-green-400' },
|
||
{ label: '1–30 days overdue', key: '1-30', color: 'text-yellow-400' },
|
||
{ label: '31–60 days overdue', key: '31-60', color: 'text-orange-400' },
|
||
{ label: '61–90 days overdue', key: '61-90', color: 'text-red-400' },
|
||
{ label: '90+ days overdue (bad debt risk)', key: '90+', color: 'text-red-600' },
|
||
].map(bucket => {
|
||
const items = aging[bucket.key as keyof typeof aging];
|
||
const total = items.reduce((s, inv) => s + Number(inv.total || 0) - Number(inv.amount_paid || 0), 0);
|
||
return (
|
||
<div key={bucket.key} className="flex items-center justify-between py-2 border-b border-gray-800 last:border-0">
|
||
<div>
|
||
<span className="text-sm text-gray-300">{bucket.label}</span>
|
||
<span className="text-xs text-gray-500 ml-2">({items.length} invoice{items.length !== 1 ? 's' : ''})</span>
|
||
</div>
|
||
<span className={`text-sm font-semibold ${bucket.color}`}>{fmt(total)}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Top Clients */}
|
||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
||
<h3 className="text-sm font-semibold text-gray-300 mb-4">Top Clients by Revenue</h3>
|
||
{topClients.length === 0 ? (
|
||
<p className="text-gray-500 text-sm">No paid invoices yet.</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{topClients.map((c, i) => (
|
||
<div key={c.name} className="flex items-center gap-3">
|
||
<span className="text-xs text-gray-500 w-5">{i + 1}</span>
|
||
<div className="flex-1">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-sm text-gray-200">{c.name}</span>
|
||
<span className="text-sm font-medium text-gray-200">{fmt(c.total)}</span>
|
||
</div>
|
||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-blue-500 rounded-full"
|
||
style={{ width: `${Math.min(100, (c.total / (topClients[0]?.total || 1)) * 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|