initial commit: rocket.new export of broadcastbeat
This commit is contained in:
195
src/app/dashboard/billing/components/ReportsTab.tsx
Normal file
195
src/app/dashboard/billing/components/ReportsTab.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user