initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

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