← back to Freddy
components/revenue/RevenueTab.tsx
254 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonList } from '../Skeleton';
import { useToast } from '../ToastProvider';
import {
DollarSign, RefreshCw, TrendingUp,
Clock, CheckCircle, FileText, AlertCircle,
} from 'lucide-react';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
interface Revenue {
id: string;
match_id: string | null;
org_id: string | null;
granter_id: string | null;
fee_type: string;
flat_fee: string;
percentage_rate: number;
grant_amount: string | null;
commission_amount: string | null;
status: string;
invoice_date: string | null;
paid_date: string | null;
notes: string | null;
created_at: string;
cause_title?: string;
org_name?: string;
granter_name?: string;
}
const SORT_OPTIONS = [
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'commission_desc', label: 'Highest Commission' },
{ value: 'fee_type', label: 'Fee Type A-Z' },
];
const SORT_CONFIGS: Record<string, SortConfig> = {
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
commission_desc: { key: 'commission_amount', direction: 'desc', type: 'number' },
fee_type: { key: 'fee_type', direction: 'asc', type: 'string' },
};
const STATUS_FILTERS = ['all', 'pending', 'invoiced', 'paid', 'waived', 'overdue'];
const fadeIn = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1, y: 0,
transition: { delay: i * 0.04, duration: 0.35 },
}),
};
export default function RevenueTab() {
const { addToast } = useToast();
const [revenue, setRevenue] = useState<Revenue[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('all');
const [stats, setStats] = useState({ total: 0, pending: 0, invoiced: 0, paid: 0 });
const [sortBy, setSortBy] = useState('date_desc');
const sortedRevenue = useClientSort(revenue, sortBy, SORT_CONFIGS);
const fetchRevenue = useCallback(async () => {
try {
const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
const res = await fetch(`/api/revenue?${params}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setRevenue(data.revenue || []);
setStats(data.stats || { total: 0, pending: 0, invoiced: 0, paid: 0 });
}
} catch (err) {
console.error('Failed to fetch revenue:', err);
addToast('Failed to load revenue data', 'error');
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => { fetchRevenue(); }, [fetchRevenue]);
const fmtMoney = (val: string | number | null) => {
if (!val) return '$0';
const n = typeof val === 'string' ? parseFloat(val) : val;
return `$${n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
};
const statusIcon = (s: string) => {
switch (s) {
case 'paid': return <CheckCircle size={14} style={{ color: '#22c55e' }} />;
case 'invoiced': return <FileText size={14} style={{ color: '#60a5fa' }} />;
case 'overdue': return <AlertCircle size={14} style={{ color: '#ef4444' }} />;
default: return <Clock size={14} style={{ color: '#fbbf24' }} />;
}
};
const statusBadge = (s: string) => {
switch (s) {
case 'paid': return 'badge-success';
case 'invoiced': return 'badge-blue';
case 'overdue': return 'badge-error';
case 'waived': return 'badge-purple';
default: return 'badge-warning';
}
};
const feeLabel = (t: string) => {
switch (t) {
case 'introduction_fee': return 'Introduction Fee';
case 'success_fee': return 'Success Fee';
case 'subscription': return 'Subscription';
case 'consulting': return 'Consulting';
default: return t;
}
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
<DollarSign size={20} style={{ color: '#d97706' }} />
Revenue
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Fee tracking, commissions, and payment status
</p>
</div>
<button onClick={fetchRevenue} className="btn btn-secondary btn-sm">
<RefreshCw size={14} /> Refresh
</button>
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="stat-card">
<div className="flex items-center gap-2 mb-2">
<TrendingUp size={14} style={{ color: '#22c55e' }} />
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Total Earned</span>
</div>
<div className="text-xl font-bold" style={{ color: '#22c55e' }}>{fmtMoney(stats.total)}</div>
</div>
<div className="stat-card">
<div className="flex items-center gap-2 mb-2">
<Clock size={14} style={{ color: '#fbbf24' }} />
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Pending</span>
</div>
<div className="text-xl font-bold" style={{ color: '#fbbf24' }}>{fmtMoney(stats.pending)}</div>
</div>
<div className="stat-card">
<div className="flex items-center gap-2 mb-2">
<FileText size={14} style={{ color: '#60a5fa' }} />
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Invoiced</span>
</div>
<div className="text-xl font-bold" style={{ color: '#60a5fa' }}>{fmtMoney(stats.invoiced)}</div>
</div>
<div className="stat-card">
<div className="flex items-center gap-2 mb-2">
<CheckCircle size={14} style={{ color: '#22c55e' }} />
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Paid</span>
</div>
<div className="text-xl font-bold" style={{ color: '#22c55e' }}>{fmtMoney(stats.paid)}</div>
</div>
</div>
{/* Status filter */}
<div className="flex items-center gap-2 flex-wrap">
<SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
{STATUS_FILTERS.map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className="btn btn-sm"
style={{
backgroundColor: statusFilter === s ? 'rgba(217, 119, 6, 0.2)' : 'var(--color-surface-el)',
color: statusFilter === s ? '#fbbf24' : 'var(--color-text-secondary)',
borderColor: statusFilter === s ? 'rgba(217, 119, 6, 0.3)' : 'var(--color-border)',
}}
>
{s === 'all' ? 'All' : s}
</button>
))}
</div>
{/* Revenue list */}
{loading ? (
<SkeletonList count={4} />
) : (
<div className="space-y-3">
<AnimatePresence>
{sortedRevenue.map((r, i) => (
<motion.div key={r.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
{statusIcon(r.status)}
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
{feeLabel(r.fee_type)}
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{r.org_name && `Org: ${r.org_name}`}
{r.granter_name && ` | Granter: ${r.granter_name}`}
{r.cause_title && ` | Cause: ${r.cause_title}`}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
{parseFloat(r.flat_fee) > 0 && (
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Flat: {fmtMoney(r.flat_fee)}
</div>
)}
{r.percentage_rate > 0 && r.grant_amount && (
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{r.percentage_rate}% of {fmtMoney(r.grant_amount)}
</div>
)}
{r.commission_amount && (
<div className="text-sm font-bold" style={{ color: '#fbbf24' }}>
{fmtMoney(r.commission_amount)}
</div>
)}
</div>
<span className={`badge ${statusBadge(r.status)}`}>{r.status}</span>
</div>
</div>
{r.notes && (
<p className="text-xs mt-2" style={{ color: 'var(--color-text-muted)' }}>{r.notes}</p>
)}
</motion.div>
))}
</AnimatePresence>
</div>
)}
{!loading && revenue.length === 0 && (
<div className="text-center py-12">
<DollarSign size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
<p style={{ color: 'var(--color-text-muted)' }}>
No revenue entries yet. Revenue is generated when matches lead to funded introductions.
</p>
</div>
)}
</div>
);
}