← back to Freddy
components/funding/FundingTab.tsx
255 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonList } from '../Skeleton';
import {
DollarSign, Plus, X, Loader2, RefreshCw,
Calendar, Landmark, Clock, CheckCircle, AlertCircle,
} from 'lucide-react';
interface FundingRound {
id: string;
title: string;
total_pool: string;
allocated: string;
remaining: string;
status: string;
opens_at: string | null;
closes_at: string | null;
cause_filter: string[];
granter_name: string;
granter_id: string;
created_at: string;
}
interface Granter {
id: string;
name: string;
}
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 FundingTab() {
const [rounds, setRounds] = useState<FundingRound[]>([]);
const [granters, setGranters] = useState<Granter[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const fetchData = useCallback(async () => {
try {
const [roundsRes, grantersRes] = await Promise.all([
fetch('/api/funding', { credentials: 'include' }),
fetch('/api/granters', { credentials: 'include' }),
]);
if (roundsRes.ok) {
const data = await roundsRes.json();
setRounds(data.funding_rounds || []);
}
if (grantersRes.ok) {
const data = await grantersRes.json();
setGranters(data.granters || []);
}
} catch (err) {
console.error('Failed to fetch funding data:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const handleCreate = async (formData: Record<string, string>) => {
try {
const res = await fetch('/api/funding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
title: formData.title,
granter_id: formData.granter_id,
total_pool: parseFloat(formData.total_pool),
opens_at: formData.opens_at || null,
closes_at: formData.closes_at || null,
}),
});
if (res.ok) {
setShowCreate(false);
await fetchData();
}
} catch (err) {
console.error('Create funding round error:', err);
}
};
const fmtMoney = (val: string | null) => {
if (!val) return '$0';
const n = parseFloat(val);
return `$${n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
};
const statusBadge = (s: string) => {
switch (s) {
case 'open': return 'badge-success';
case 'completed': return 'badge-blue';
case 'closed': return 'badge-error';
default: return 'badge-warning';
}
};
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: '#22c55e' }} />
Funding Pipeline
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Granter-initiated funding rounds and allocation tracking
</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
<Plus size={14} /> Create Funding Round
</button>
<button onClick={fetchData} className="btn btn-secondary btn-sm">
<RefreshCw size={14} />
</button>
</div>
</div>
{loading ? (
<SkeletonList count={4} />
) : (
<div className="space-y-4">
<AnimatePresence>
{rounds.map((round, i) => {
const total = parseFloat(round.total_pool) || 1;
const allocated = parseFloat(round.allocated) || 0;
const remaining = parseFloat(round.remaining) || 0;
const pct = Math.min(100, (allocated / total) * 100);
return (
<motion.div key={round.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-5">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>{round.title}</h3>
<div className="flex items-center gap-2 mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<Landmark size={12} /> {round.granter_name}
{round.opens_at && (
<span className="flex items-center gap-1"><Calendar size={10} /> Opens: {new Date(round.opens_at).toLocaleDateString()}</span>
)}
{round.closes_at && (
<span className="flex items-center gap-1"><Clock size={10} /> Closes: {new Date(round.closes_at).toLocaleDateString()}</span>
)}
</div>
</div>
<span className={`badge ${statusBadge(round.status)}`}>{round.status}</span>
</div>
<div className="grid grid-cols-3 gap-3 mb-3">
<div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Total Pool</div>
<div className="text-sm font-bold" style={{ color: 'var(--color-text)' }}>{fmtMoney(round.total_pool)}</div>
</div>
<div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Allocated</div>
<div className="text-sm font-bold" style={{ color: '#fbbf24' }}>{fmtMoney(round.allocated)}</div>
</div>
<div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Remaining</div>
<div className="text-sm font-bold" style={{ color: '#22c55e' }}>{fmtMoney(round.remaining)}</div>
</div>
</div>
<div className="flex items-center justify-between text-xs mb-1" style={{ color: 'var(--color-text-muted)' }}>
<span>Allocation Progress</span>
<span>{pct.toFixed(0)}%</span>
</div>
<div className="urgency-gauge">
<div className="urgency-gauge-fill" style={{ width: `${pct}%`, background: 'linear-gradient(90deg, #d97706, #fbbf24)' }} />
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
)}
{!loading && rounds.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 funding rounds yet. Create one to start allocating grants.</p>
</div>
)}
{showCreate && <CreateFundingModal granters={granters} onClose={() => setShowCreate(false)} onSubmit={handleCreate} />}
</div>
);
}
function CreateFundingModal({ granters, onClose, onSubmit }: { granters: Granter[]; onClose: () => void; onSubmit: (data: Record<string, string>) => void }) {
const [form, setForm] = useState({ title: '', granter_id: '', total_pool: '', opens_at: '', closes_at: '' });
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
setSubmitting(true);
await onSubmit(form);
setSubmitting(false);
};
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => setForm(f => ({ ...f, [k]: e.target.value }));
return (
<div className="modal-overlay" onClick={onClose}>
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Create Funding Round</h3>
<button onClick={onClose} className="btn btn-ghost btn-sm"><X size={16} /></button>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Title *</label>
<input className="input" value={form.title} onChange={set('title')} placeholder="2024 Education Grant Round" />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Granter *</label>
<select className="input" value={form.granter_id} onChange={set('granter_id')}>
<option value="">Select a granter...</option>
{granters.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Total Pool ($) *</label>
<input className="input" type="number" value={form.total_pool} onChange={set('total_pool')} placeholder="100000" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Opens</label>
<input className="input" type="date" value={form.opens_at} onChange={set('opens_at')} />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Closes</label>
<input className="input" type="date" value={form.closes_at} onChange={set('closes_at')} />
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="btn btn-secondary">Cancel</button>
<button onClick={handleSubmit} disabled={!form.title || !form.granter_id || !form.total_pool || submitting} className="btn btn-primary">
{submitting ? <Loader2 size={14} className="animate-spin" /> : null}
Create Round
</button>
</div>
</div>
</motion.div>
</div>
);
}