Files
avbeat-com/src/app/dashboard/billing/components/ReportsTab.tsx
2026-05-07 16:39:17 +00:00

196 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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: '130 days overdue', key: '1-30', color: 'text-yellow-400' },
{ label: '3160 days overdue', key: '31-60', color: 'text-orange-400' },
{ label: '6190 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>
);
}