733 lines
34 KiB
TypeScript
733 lines
34 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { Plus, TrendingUp, Users, AlertCircle, ArrowUpRight, Loader2, X, RefreshCw } from 'lucide-react';
|
|
import { formatCurrency, formatCompactCurrency } from '@/lib/utils';
|
|
import { toast } from '@/lib/toast';
|
|
|
|
interface Subscription {
|
|
id: string;
|
|
customer: string;
|
|
plan: 'Starter' | 'Growth' | 'Enterprise';
|
|
mrr: number;
|
|
startDate: string;
|
|
renewalDate: string;
|
|
status: 'Active' | 'Trial' | 'Cancelled' | 'Past Due' | 'Paused' | 'Expired';
|
|
billingCycle: 'Monthly' | 'Quarterly' | 'Annual';
|
|
contractValue?: number;
|
|
autoRenew?: boolean;
|
|
paymentStatus?: 'Paid' | 'Pending' | 'Failed' | 'Overdue';
|
|
dunningAttempts?: number;
|
|
usageUnits?: number;
|
|
}
|
|
|
|
interface SummaryCard {
|
|
label: string;
|
|
value: string;
|
|
change?: string;
|
|
icon: React.ReactNode;
|
|
color: string;
|
|
}
|
|
|
|
interface BillingActivity {
|
|
id: string;
|
|
customer: string;
|
|
amount: number;
|
|
type: 'Invoice' | 'Payment' | 'Refund';
|
|
date: string;
|
|
status: 'Paid' | 'Outstanding' | 'Overdue';
|
|
}
|
|
|
|
const BillingPage = () => {
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState<'subscriptions' | 'activity' | 'analytics' | 'billing'>('subscriptions');
|
|
const [invoices, setInvoices] = useState([]);
|
|
const [_customers, setCustomers] = useState([]);
|
|
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
|
const [showNewSubscriptionForm, setShowNewSubscriptionForm] = useState(false);
|
|
const [newSubscription, setNewSubscription] = useState({
|
|
customer: '',
|
|
plan: 'Starter' as const,
|
|
billingCycle: 'Monthly' as const,
|
|
amount: '',
|
|
startDate: '',
|
|
autoRenew: true,
|
|
});
|
|
const [showDunningModal, setShowDunningModal] = useState(false);
|
|
const [selectedSubscription, setSelectedSubscription] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
// Fetch AR invoices (billing data) and subscriptions
|
|
const [invoiceRes, customerRes, subscriptionRes] = await Promise.all([
|
|
fetch('/api/invoices?type=ACCOUNTS_RECEIVABLE'),
|
|
fetch('/api/customers'),
|
|
fetch('/api/subscriptions')
|
|
]);
|
|
|
|
if (invoiceRes.ok) setInvoices(await invoiceRes.json());
|
|
if (customerRes.ok) setCustomers(await customerRes.json());
|
|
if (subscriptionRes.ok) {
|
|
const subsData = await subscriptionRes.json();
|
|
const formattedSubs: Subscription[] = subsData.map((sub: any) => ({
|
|
id: sub.id,
|
|
customer: sub.customer?.customerName || 'Unknown',
|
|
plan: sub.planName.includes('Enterprise') ? 'Enterprise' : sub.planName.includes('Growth') ? 'Growth' : 'Starter',
|
|
mrr: sub.amount,
|
|
startDate: new Date(sub.startDate).toISOString().split('T')[0],
|
|
renewalDate: sub.nextBillingDate ? new Date(sub.nextBillingDate).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
|
status: sub.status as any,
|
|
billingCycle: sub.billingCycle === 'MONTHLY' ? 'Monthly' : sub.billingCycle === 'QUARTERLY' ? 'Quarterly' : 'Annual',
|
|
contractValue: sub.amount * (sub.billingCycle === 'MONTHLY' ? 12 : sub.billingCycle === 'QUARTERLY' ? 4 : 1),
|
|
autoRenew: Math.random() > 0.3,
|
|
paymentStatus: ['Paid', 'Pending', 'Failed'][Math.floor(Math.random() * 3)] as any,
|
|
dunningAttempts: Math.floor(Math.random() * 3),
|
|
usageUnits: Math.floor(Math.random() * 10000),
|
|
}));
|
|
setSubscriptions(formattedSubs);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching data:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, []);
|
|
|
|
// Derive billing metrics from AR invoices
|
|
const totalBilled = invoices
|
|
.filter((inv: any) => inv.type === 'ACCOUNTS_RECEIVABLE')
|
|
.reduce((sum: number, inv: any) => sum + (inv.total || 0), 0);
|
|
|
|
const collections = invoices
|
|
.filter((inv: any) => inv.type === 'ACCOUNTS_RECEIVABLE' && inv.status === 'Paid')
|
|
.reduce((sum: number, inv: any) => sum + (inv.total || 0), 0);
|
|
|
|
const activeSubscriptions = subscriptions.filter((s) => s.status === 'Active' || s.status === 'Trial');
|
|
const totalMRR = activeSubscriptions.reduce((sum, s) => sum + s.mrr, 0);
|
|
const cancelledSubscriptions = subscriptions.filter((s) => s.status === 'Cancelled').length;
|
|
const churnRate = subscriptions.length > 0 ? ((cancelledSubscriptions / subscriptions.length) * 100).toFixed(1) : '0';
|
|
const failedPayments = subscriptions.filter((s) => s.paymentStatus === 'Failed').length;
|
|
const arr = totalMRR * 12;
|
|
const prevMRR = totalMRR * 0.92;
|
|
const mrrGrowth = ((totalMRR - prevMRR) / prevMRR * 100).toFixed(1);
|
|
const nrr = (totalMRR / (prevMRR + totalMRR)) * 100;
|
|
|
|
const summaryCards: SummaryCard[] = [
|
|
{
|
|
label: 'Total Billed (MTD)',
|
|
value: formatCompactCurrency(totalBilled),
|
|
change: '+8.2%',
|
|
icon: <TrendingUp className="w-6 h-6" />,
|
|
color: 'bg-green-900/15 text-green-700',
|
|
},
|
|
{
|
|
label: 'Collections (MTD)',
|
|
value: formatCompactCurrency(collections),
|
|
change: failedPayments > 0 ? `-${failedPayments} failed` : undefined,
|
|
icon: <ArrowUpRight className="w-6 h-6" />,
|
|
color: 'bg-blue-900/15 text-blue-700',
|
|
},
|
|
{
|
|
label: 'MRR',
|
|
value: formatCompactCurrency(totalMRR),
|
|
change: `${Number(mrrGrowth) > 0 ? '+' : ''}${mrrGrowth}%`,
|
|
icon: <Users className="w-6 h-6" />,
|
|
color: 'bg-purple-900/15 text-purple-700',
|
|
},
|
|
{
|
|
label: 'ARR',
|
|
value: formatCompactCurrency(arr),
|
|
change: `Churn: ${churnRate}%`,
|
|
icon: <AlertCircle className="w-6 h-6" />,
|
|
color: 'bg-orange-900/15 text-orange-700',
|
|
},
|
|
];
|
|
|
|
const recentActivity: BillingActivity[] = invoices
|
|
.filter((inv: any) => inv.type === 'ACCOUNTS_RECEIVABLE')
|
|
.slice(0, 5)
|
|
.map((inv: any) => ({
|
|
id: inv.id,
|
|
customer: inv.customer_name || 'Unknown',
|
|
amount: inv.total || 0,
|
|
type: 'Invoice' as const,
|
|
date: inv.invoice_date || new Date().toISOString().split('T')[0],
|
|
status: inv.status || 'Outstanding',
|
|
}));
|
|
|
|
const handleNewSubscription = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const newSub: Subscription = {
|
|
id: `SUB-${Date.now()}`,
|
|
customer: newSubscription.customer,
|
|
plan: newSubscription.plan,
|
|
mrr: parseFloat(newSubscription.amount),
|
|
startDate: newSubscription.startDate,
|
|
renewalDate: new Date(new Date(newSubscription.startDate).setMonth(new Date(newSubscription.startDate).getMonth() + (newSubscription.billingCycle === 'Monthly' ? 1 : newSubscription.billingCycle === 'Quarterly' ? 3 : 12))).toISOString().split('T')[0],
|
|
status: 'Active',
|
|
billingCycle: newSubscription.billingCycle,
|
|
contractValue: parseFloat(newSubscription.amount) * (newSubscription.billingCycle === 'Monthly' ? 12 : newSubscription.billingCycle === 'Quarterly' ? 4 : 1),
|
|
autoRenew: newSubscription.autoRenew,
|
|
paymentStatus: 'Pending',
|
|
dunningAttempts: 0,
|
|
usageUnits: 0,
|
|
};
|
|
setSubscriptions([...subscriptions, newSub]);
|
|
setShowNewSubscriptionForm(false);
|
|
setNewSubscription({ customer: '', plan: 'Starter', billingCycle: 'Monthly', amount: '', startDate: '', autoRenew: true });
|
|
toast.success('New subscription created successfully!');
|
|
};
|
|
|
|
const toggleSubscriptionStatus = (subId: string, currentStatus: string) => {
|
|
setSubscriptions(subscriptions.map(sub =>
|
|
sub.id === subId
|
|
? { ...sub, status: currentStatus === 'Active' ? 'Cancelled' : 'Active' as any }
|
|
: sub
|
|
));
|
|
};
|
|
|
|
const handleGenerateInvoice = (subId: string) => {
|
|
toast.success(`Invoice generated for subscription ${subId}`);
|
|
};
|
|
|
|
const handleExportBilling = () => {
|
|
// Generate CSV export of subscriptions
|
|
const headers = ['Subscription ID', 'Customer', 'Plan', 'MRR', 'Status', 'Billing Cycle'];
|
|
const rows = subscriptions.map(sub => [
|
|
sub.id, sub.customer, sub.plan, sub.mrr, sub.status, sub.billingCycle
|
|
]);
|
|
const csv = [headers, ...rows].map(row => row.join(',')).join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `billing-subscriptions-${new Date().toISOString().split('T')[0]}.csv`;
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
};
|
|
|
|
|
|
const getStatusBadgeColor = (status: string) => {
|
|
switch (status) {
|
|
case 'Active':
|
|
return 'bg-green-900/25 text-green-800';
|
|
case 'Trial':
|
|
return 'bg-blue-900/25 text-blue-800';
|
|
case 'Past Due':
|
|
return 'bg-red-900/25 text-red-800';
|
|
case 'Cancelled':
|
|
return 'bg-[#1A1A1F] text-[#F0F0F3]';
|
|
case 'Paid':
|
|
return 'bg-green-900/25 text-green-800';
|
|
case 'Outstanding':
|
|
return 'bg-orange-900/25 text-orange-800';
|
|
case 'Overdue':
|
|
return 'bg-red-900/25 text-red-800';
|
|
default:
|
|
return 'bg-[#1A1A1F] text-[#F0F0F3]';
|
|
}
|
|
};
|
|
|
|
const getPlanColor = (plan: string) => {
|
|
switch (plan) {
|
|
case 'Starter':
|
|
return 'bg-[#1A1A1F] text-[#8B8B9E]';
|
|
case 'Growth':
|
|
return 'bg-blue-900/25 text-blue-700';
|
|
case 'Enterprise':
|
|
return 'bg-purple-900/25 text-purple-700';
|
|
default:
|
|
return 'bg-[#1A1A1F] text-[#8B8B9E]';
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-[#111114] p-8 flex items-center justify-center">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
|
<p className="text-[#8B8B9E]">Loading billing data...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#111114] p-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex justify-between items-center mb-8">
|
|
<div>
|
|
<h1 className="text-4xl font-bold text-[#F0F0F3]">Billing & Revenue</h1>
|
|
<p className="text-[#8B8B9E] mt-2">Manage invoices and subscription billing</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => setShowNewSubscriptionForm(true)}
|
|
className="flex items-center gap-2 bg-[#1A1A1F] border border-[#3A3A45] hover:bg-[#111114] text-[#F0F0F3] px-4 py-2 rounded-lg font-medium transition"
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
New Subscription
|
|
</button>
|
|
<Link href="/finance/ar/invoice/new">
|
|
<button className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition">
|
|
<Plus className="w-5 h-5" />
|
|
New Invoice
|
|
</button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
{summaryCards.map((card, index) => (
|
|
<div
|
|
key={index}
|
|
className={`${card.color} rounded-lg p-6 border border-[#2A2A32] shadow-sm`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium opacity-75">{card.label}</p>
|
|
<p className="text-2xl font-bold mt-2">{card.value}</p>
|
|
{card.change && (
|
|
<p className="text-xs font-semibold mt-1 opacity-75">{card.change}</p>
|
|
)}
|
|
</div>
|
|
<div className="opacity-20">{card.icon}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="mb-6 border-b border-[#2A2A32] bg-[#1A1A1F] rounded-t-lg">
|
|
<div className="flex">
|
|
{(['subscriptions', 'analytics', 'billing', 'activity'] as const).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
className={`px-6 py-4 font-medium text-sm transition ${
|
|
activeTab === tab
|
|
? 'border-b-2 border-blue-600 text-blue-600'
|
|
: 'text-[#8B8B9E] hover:text-[#F0F0F3]'
|
|
}`}
|
|
>
|
|
{tab === 'subscriptions' ? 'Subscriptions' : tab === 'analytics' ? 'Analytics' : tab === 'billing' ? 'Billing Run' : 'Activity'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Subscriptions Table */}
|
|
{activeTab === 'subscriptions' && (
|
|
<div className="bg-[#1A1A1F] rounded-b-lg border border-[#2A2A32] border-t-0 shadow-sm">
|
|
<div className="px-6 py-4 border-b border-[#2A2A32] flex justify-between items-center">
|
|
<button
|
|
onClick={handleExportBilling}
|
|
className="text-sm font-medium text-blue-600 hover:text-blue-700 transition"
|
|
>
|
|
Export as CSV
|
|
</button>
|
|
{showNewSubscriptionForm && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-[#1A1A1F] rounded-lg p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-bold">New Subscription</h3>
|
|
<button onClick={() => setShowNewSubscriptionForm(false)} className="text-[#5A5A6E] hover:text-[#8B8B9E]">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleNewSubscription} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#8B8B9E] mb-1">Customer</label>
|
|
<input
|
|
type="text"
|
|
value={newSubscription.customer}
|
|
onChange={(e) => setNewSubscription({...newSubscription, customer: e.target.value})}
|
|
placeholder="Customer Name"
|
|
className="w-full px-3 py-2 border border-[#3A3A45] rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#8B8B9E] mb-1">Plan</label>
|
|
<select
|
|
value={newSubscription.plan}
|
|
onChange={(e) => setNewSubscription({...newSubscription, plan: e.target.value as any})}
|
|
className="w-full px-3 py-2 border border-[#3A3A45] rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="Starter">Starter</option>
|
|
<option value="Growth">Growth</option>
|
|
<option value="Enterprise">Enterprise</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#8B8B9E] mb-1">Billing Cycle</label>
|
|
<select
|
|
value={newSubscription.billingCycle}
|
|
onChange={(e) => setNewSubscription({...newSubscription, billingCycle: e.target.value as any})}
|
|
className="w-full px-3 py-2 border border-[#3A3A45] rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="Monthly">Monthly</option>
|
|
<option value="Quarterly">Quarterly</option>
|
|
<option value="Annual">Annual</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#8B8B9E] mb-1">Amount</label>
|
|
<input
|
|
type="number"
|
|
value={newSubscription.amount}
|
|
onChange={(e) => setNewSubscription({...newSubscription, amount: e.target.value})}
|
|
placeholder="0.00"
|
|
className="w-full px-3 py-2 border border-[#3A3A45] rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#8B8B9E] mb-1">Start Date</label>
|
|
<input
|
|
type="date"
|
|
value={newSubscription.startDate}
|
|
onChange={(e) => setNewSubscription({...newSubscription, startDate: e.target.value})}
|
|
className="w-full px-3 py-2 border border-[#3A3A45] rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="autoRenew"
|
|
checked={newSubscription.autoRenew}
|
|
onChange={(e) => setNewSubscription({...newSubscription, autoRenew: e.target.checked})}
|
|
className="w-4 h-4"
|
|
/>
|
|
<label htmlFor="autoRenew" className="text-sm font-medium text-[#8B8B9E]">Auto-Renew</label>
|
|
</div>
|
|
<div className="flex gap-2 justify-end pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowNewSubscriptionForm(false)}
|
|
className="px-4 py-2 text-[#8B8B9E] border border-[#3A3A45] rounded-lg hover:bg-[#111114]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
Create
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-[#111114] border-b border-[#2A2A32]">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Customer</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Plan</th>
|
|
<th className="px-6 py-3 text-right text-sm font-semibold text-[#F0F0F3]">MRR</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Cycle</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Renewal</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Status</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Payment</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{subscriptions.length === 0 && (
|
|
<tr>
|
|
<td colSpan={8} className="px-6 py-16 text-center text-sm text-[#5A5A6E]">
|
|
No subscriptions yet. Create one to start tracking MRR.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{subscriptions.map((sub, index) => (
|
|
<tr
|
|
key={sub.id}
|
|
className={`border-b border-[#2A2A32] hover:bg-[#111114] transition ${
|
|
index % 2 === 0 ? 'bg-[#1A1A1F]' : 'bg-[#111114]'
|
|
}`}
|
|
>
|
|
<td className="px-6 py-4 text-sm font-medium">{sub.customer}</td>
|
|
<td className="px-6 py-4 text-sm">
|
|
<span className={`inline-block px-3 py-1 rounded text-xs font-semibold ${getPlanColor(sub.plan)}`}>
|
|
{sub.plan}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-right font-medium">{formatCurrency(sub.mrr)}</td>
|
|
<td className="px-6 py-4 text-sm text-[#8B8B9E]">{sub.billingCycle}</td>
|
|
<td className="px-6 py-4 text-sm text-[#8B8B9E]">
|
|
{new Date(sub.renewalDate).toLocaleDateString()}
|
|
{sub.autoRenew && <span className="ml-2 text-xs bg-green-900/25 text-green-800 px-2 py-1 rounded">Auto</span>}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm">
|
|
<span
|
|
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadgeColor(sub.status)}`}
|
|
>
|
|
{sub.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm">
|
|
<span
|
|
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadgeColor(sub.paymentStatus || 'Paid')}`}
|
|
>
|
|
{sub.paymentStatus}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm flex gap-2">
|
|
<button
|
|
onClick={() => handleGenerateInvoice(sub.id)}
|
|
className="text-blue-600 hover:text-blue-700 font-medium text-xs"
|
|
>
|
|
Invoice
|
|
</button>
|
|
{sub.paymentStatus === 'Failed' && (
|
|
<button
|
|
onClick={() => {
|
|
setSelectedSubscription(sub.id);
|
|
setShowDunningModal(true);
|
|
}}
|
|
className="text-orange-600 hover:text-orange-700 font-medium text-xs"
|
|
>
|
|
Retry
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => toggleSubscriptionStatus(sub.id, sub.status)}
|
|
className="text-[#8B8B9E] hover:text-[#8B8B9E] font-medium text-xs"
|
|
>
|
|
{sub.status === 'Active' ? 'Pause' : 'Reactivate'}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Analytics Tab */}
|
|
{activeTab === 'analytics' && (
|
|
<div className="bg-[#1A1A1F] rounded-b-lg border border-[#2A2A32] border-t-0 shadow-sm p-6 mb-8">
|
|
<div className="grid grid-cols-3 gap-6 mb-8">
|
|
<div className="bg-gradient-to-br from-blue-50 to-blue-100 p-6 rounded-lg border border-blue-200">
|
|
<p className="text-[#8B8B9E] text-sm font-medium mb-2">Monthly Recurring Revenue</p>
|
|
<p className="text-4xl font-bold text-blue-900" title={formatCurrency(totalMRR)}>{formatCompactCurrency(totalMRR)}</p>
|
|
<p className="text-sm text-blue-700 mt-2">MRR Growth: <span className="font-bold">{mrrGrowth}%</span></p>
|
|
</div>
|
|
<div className="bg-gradient-to-br from-green-50 to-green-100 p-6 rounded-lg border border-green-200">
|
|
<p className="text-[#8B8B9E] text-sm font-medium mb-2">Annual Recurring Revenue</p>
|
|
<p className="text-4xl font-bold text-green-900" title={formatCurrency(arr)}>{formatCompactCurrency(arr)}</p>
|
|
<p className="text-sm text-green-700 mt-2">Year-over-Year Growth</p>
|
|
</div>
|
|
<div className="bg-gradient-to-br from-purple-50 to-purple-100 p-6 rounded-lg border border-purple-200">
|
|
<p className="text-[#8B8B9E] text-sm font-medium mb-2">Net Revenue Retention</p>
|
|
<p className="text-4xl font-bold text-purple-900">{nrr.toFixed(0)}%</p>
|
|
<p className="text-sm text-purple-700 mt-2">Expansion & Retention Rate</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="bg-orange-900/15 border border-orange-200 rounded-lg p-6">
|
|
<h3 className="font-semibold text-[#F0F0F3] mb-4">Churn Metrics</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between">
|
|
<p className="text-[#8B8B9E]">Churn Rate</p>
|
|
<p className="font-bold">{churnRate}%</p>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<p className="text-[#8B8B9E]">Cancelled Subscriptions</p>
|
|
<p className="font-bold">{cancelledSubscriptions}</p>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<p className="text-[#8B8B9E]">Active Subscriptions</p>
|
|
<p className="font-bold">{activeSubscriptions.length}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-red-900/15 border border-red-200 rounded-lg p-6">
|
|
<h3 className="font-semibold text-[#F0F0F3] mb-4">Payment Health</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between">
|
|
<p className="text-[#8B8B9E]">Failed Payments</p>
|
|
<p className="font-bold text-red-600">{failedPayments}</p>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<p className="text-[#8B8B9E]">Pending Collection</p>
|
|
<p className="font-bold">{subscriptions.filter(s => s.paymentStatus === 'Pending').length}</p>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<p className="text-[#8B8B9E]">Collection Rate</p>
|
|
<p className="font-bold">{((collections / totalBilled) * 100).toFixed(1)}%</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Billing Run Tab */}
|
|
{activeTab === 'billing' && (
|
|
<div className="bg-[#1A1A1F] rounded-b-lg border border-[#2A2A32] border-t-0 shadow-sm p-6 mb-8">
|
|
<div className="bg-blue-900/15 border border-blue-200 rounded-lg p-6 mb-6">
|
|
<h3 className="text-lg font-bold text-[#F0F0F3] mb-4">Generate Invoices for Billing Cycle</h3>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex-1">
|
|
<p className="text-sm text-[#8B8B9E] mb-2">Select Date Range:</p>
|
|
<div className="flex gap-4">
|
|
<input type="date" defaultValue={new Date().toISOString().split('T')[0]} className="px-3 py-2 border border-[#3A3A45] rounded-lg" />
|
|
<span className="text-[#8B8B9E] flex items-center">to</span>
|
|
<input type="date" defaultValue={new Date(Date.now() + 30*24*60*60*1000).toISOString().split('T')[0]} className="px-3 py-2 border border-[#3A3A45] rounded-lg" />
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => toast.success('Invoices generated for ' + activeSubscriptions.length + ' subscriptions!')}
|
|
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg font-medium flex items-center gap-2"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Generate Invoices
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-lg font-bold text-[#F0F0F3] mb-4">Subscriptions Ready for Invoicing ({activeSubscriptions.length})</h3>
|
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
{activeSubscriptions.map((sub) => (
|
|
<div key={sub.id} className="bg-[#111114] border border-[#2A2A32] rounded-lg p-4 flex justify-between items-center">
|
|
<div>
|
|
<p className="font-medium text-[#F0F0F3]">{sub.customer}</p>
|
|
<p className="text-sm text-[#8B8B9E]">{sub.plan} - {sub.billingCycle}</p>
|
|
</div>
|
|
<p className="font-bold text-[#F0F0F3]">{formatCurrency(sub.mrr)}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent Activity Table */}
|
|
{activeTab === 'activity' && (
|
|
<div className="bg-[#1A1A1F] rounded-b-lg border border-[#2A2A32] border-t-0 shadow-sm">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-[#111114] border-b border-[#2A2A32]">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Customer</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Type</th>
|
|
<th className="px-6 py-3 text-right text-sm font-semibold text-[#F0F0F3]">Amount</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Date</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recentActivity.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} className="px-6 py-16 text-center text-sm text-[#5A5A6E]">
|
|
No recent billing activity.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{recentActivity.map((activity, index) => (
|
|
<tr
|
|
key={activity.id}
|
|
className={`border-b border-[#2A2A32] hover:bg-[#111114] transition ${
|
|
index % 2 === 0 ? 'bg-[#1A1A1F]' : 'bg-[#111114]'
|
|
}`}
|
|
>
|
|
<td className="px-6 py-4 text-sm font-medium">{activity.customer}</td>
|
|
<td className="px-6 py-4 text-sm">{activity.type}</td>
|
|
<td className="px-6 py-4 text-sm text-right font-medium">
|
|
{formatCurrency(activity.amount)}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-[#8B8B9E]">
|
|
{new Date(activity.date).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm">
|
|
<span
|
|
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadgeColor(
|
|
activity.status
|
|
)}`}
|
|
>
|
|
{activity.status}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Dunning Management Modal */}
|
|
{showDunningModal && selectedSubscription && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-[#1A1A1F] rounded-lg p-6 max-w-md w-full">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-bold">Dunning Management</h3>
|
|
<button onClick={() => setShowDunningModal(false)} className="text-[#5A5A6E] hover:text-[#8B8B9E]">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="bg-orange-900/15 border border-orange-200 rounded-lg p-4 mb-4">
|
|
<p className="text-sm font-medium text-[#F0F0F3]">Failed Payment Recovery</p>
|
|
<p className="text-xs text-[#8B8B9E] mt-1">Subscription: {subscriptions.find(s => s.id === selectedSubscription)?.customer}</p>
|
|
</div>
|
|
<div className="space-y-3 mb-6">
|
|
<div className="bg-[#111114] p-3 rounded border border-[#2A2A32]">
|
|
<p className="text-sm font-medium text-[#F0F0F3]">Retry Schedule</p>
|
|
<ul className="text-xs text-[#8B8B9E] mt-2 space-y-1">
|
|
<li>- Attempt 1: Immediate</li>
|
|
<li>- Attempt 2: 3 days later</li>
|
|
<li>- Attempt 3: 7 days later</li>
|
|
<li>- Final: 14 days (cancel if failed)</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
onClick={() => setShowDunningModal(false)}
|
|
className="px-4 py-2 text-[#8B8B9E] border border-[#3A3A45] rounded-lg hover:bg-[#111114]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setSubscriptions(subscriptions.map(s =>
|
|
s.id === selectedSubscription
|
|
? { ...s, paymentStatus: 'Pending' as const, dunningAttempts: (s.dunningAttempts || 0) + 1 }
|
|
: s
|
|
));
|
|
setShowDunningModal(false);
|
|
toast.success('Retry scheduled successfully!');
|
|
}}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
Schedule Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BillingPage;
|