169 lines
6.2 KiB
TypeScript
169 lines
6.2 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
|
|
|
interface Props {
|
|
companyId?: string;
|
|
selectedCompany: 'rmp' | 'jtr';
|
|
}
|
|
|
|
interface Stats {
|
|
revenueThisMonth: number;
|
|
revenueLastMonth: number;
|
|
revenueChange: number;
|
|
outstandingCount: number;
|
|
outstandingAmount: number;
|
|
overdueCount: number;
|
|
overdueAmount: number;
|
|
paidThisMonthAmount: number;
|
|
upcomingCount: number;
|
|
upcomingAmount: number;
|
|
}
|
|
|
|
function fmt(n: number) {
|
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
|
|
}
|
|
|
|
export default function OverviewTab({ companyId, selectedCompany }: Props) {
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [monthlyRevenue, setMonthlyRevenue] = useState<{ month: string; revenue: number }[]>([]);
|
|
const [recentInvoices, setRecentInvoices] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
const params = companyId ? `?companyId=${companyId}` : '';
|
|
Promise.all([
|
|
fetch(`/api/billing/stats${params}`).then(r => r.json()),
|
|
fetch(`/api/billing/invoices${params}&limit=10`).then(r => r.json()),
|
|
]).then(([statsData, invData]) => {
|
|
setStats(statsData.stats);
|
|
setMonthlyRevenue(statsData.monthlyRevenue || []);
|
|
setRecentInvoices((invData.data || []).slice(0, 10));
|
|
}).finally(() => setLoading(false));
|
|
}, [companyId]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const cards = [
|
|
{
|
|
label: 'Revenue This Month',
|
|
value: fmt(stats?.revenueThisMonth || 0),
|
|
sub: stats?.revenueChange !== undefined
|
|
? `${stats.revenueChange >= 0 ? '+' : ''}${stats.revenueChange.toFixed(1)}% vs last month`
|
|
: '',
|
|
color: 'text-green-400',
|
|
bg: 'bg-green-900/20 border-green-800',
|
|
},
|
|
{
|
|
label: 'Outstanding Invoices',
|
|
value: fmt(stats?.outstandingAmount || 0),
|
|
sub: `${stats?.outstandingCount || 0} invoices`,
|
|
color: 'text-blue-400',
|
|
bg: 'bg-blue-900/20 border-blue-800',
|
|
},
|
|
{
|
|
label: 'Overdue',
|
|
value: fmt(stats?.overdueAmount || 0),
|
|
sub: `${stats?.overdueCount || 0} invoices`,
|
|
color: 'text-red-400',
|
|
bg: 'bg-red-900/20 border-red-800',
|
|
},
|
|
{
|
|
label: 'Paid This Month',
|
|
value: fmt(stats?.paidThisMonthAmount || 0),
|
|
sub: '',
|
|
color: 'text-emerald-400',
|
|
bg: 'bg-emerald-900/20 border-emerald-800',
|
|
},
|
|
{
|
|
label: 'Due in 7 Days',
|
|
value: fmt(stats?.upcomingAmount || 0),
|
|
sub: `${stats?.upcomingCount || 0} invoices`,
|
|
color: 'text-yellow-400',
|
|
bg: 'bg-yellow-900/20 border-yellow-800',
|
|
},
|
|
];
|
|
|
|
const statusColors: Record<string, string> = {
|
|
draft: 'bg-gray-700 text-gray-300',
|
|
sent: 'bg-blue-900 text-blue-300',
|
|
viewed: 'bg-purple-900 text-purple-300',
|
|
paid: 'bg-green-900 text-green-300',
|
|
overdue: 'bg-red-900 text-red-300',
|
|
cancelled: 'bg-gray-800 text-gray-400',
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
|
{cards.map(card => (
|
|
<div key={card.label} className={`rounded-xl border p-4 ${card.bg}`}>
|
|
<p className="text-xs text-gray-400 mb-1">{card.label}</p>
|
|
<p className={`text-xl font-bold ${card.color}`}>{card.value}</p>
|
|
{card.sub && <p className="text-xs text-gray-500 mt-1">{card.sub}</p>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Revenue Chart */}
|
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
|
<h3 className="text-sm font-semibold text-gray-300 mb-4">Revenue — Last 12 Months</h3>
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<LineChart data={monthlyRevenue}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
|
<XAxis dataKey="month" tick={{ fill: '#9ca3af', fontSize: 11 }} />
|
|
<YAxis tick={{ fill: '#9ca3af', fontSize: 11 }} tickFormatter={v => `$${(v / 1000).toFixed(0)}k`} />
|
|
<Tooltip
|
|
contentStyle={{ background: '#1f2937', border: '1px solid #374151', borderRadius: 8 }}
|
|
labelStyle={{ color: '#e5e7eb' }}
|
|
formatter={(v: any) => [fmt(v), 'Revenue']}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke={selectedCompany === 'rmp' ? '#F0A623' : '#a855f7'}
|
|
strokeWidth={2}
|
|
dot={{ fill: selectedCompany === 'rmp' ? '#F0A623' : '#a855f7', r: 3 }}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Recent Activity */}
|
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
|
|
<h3 className="text-sm font-semibold text-gray-300 mb-4">Recent Invoice Activity</h3>
|
|
{recentInvoices.length === 0 ? (
|
|
<p className="text-gray-500 text-sm text-center py-8">No recent activity. Sync from MoonInvoice or create your first invoice.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{recentInvoices.map((inv: any) => (
|
|
<div key={inv.id} className="flex items-center justify-between py-2 border-b border-gray-800 last:border-0">
|
|
<div className="flex items-center gap-3">
|
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColors[inv.status] || 'bg-gray-700 text-gray-300'}`}>
|
|
{inv.status}
|
|
</span>
|
|
<div>
|
|
<span className="text-sm text-gray-200">{inv.invoice_number}</span>
|
|
<span className="text-gray-500 mx-2">·</span>
|
|
<span className="text-sm text-gray-400">{inv.billing_clients?.name || '—'}</span>
|
|
</div>
|
|
</div>
|
|
<span className="text-sm font-medium text-gray-200">{fmt(inv.total || 0)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|