← back to Freddy
components/granters/GrantersTab.tsx
309 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonList } from '../Skeleton';
import {
Landmark, Plus, X, Loader2, ExternalLink,
DollarSign, Tag, MapPin, Calendar,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
interface Granter {
id: string;
name: string;
description: string | null;
website_url: string | null;
city: string | null;
state: string | null;
funding_areas: string[];
annual_budget: string | null;
min_grant: string | null;
max_grant: string | null;
grants_per_year: number | null;
preference_tags: string[];
status: string;
created_at: string;
}
const SORT_OPTIONS = [
{ value: 'name_asc', label: 'Name A-Z' },
{ value: 'name_desc', label: 'Name Z-A' },
{ value: 'budget_desc', label: 'Largest Budget' },
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
];
const SORT_CONFIGS: Record<string, SortConfig> = {
name_asc: { key: 'name', direction: 'asc', type: 'string' },
name_desc: { key: 'name', direction: 'desc', type: 'string' },
budget_desc:{ key: 'annual_budget', direction: 'desc', type: 'number' },
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
};
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 GrantersTab() {
const { addToast } = useToast();
const [granters, setGranters] = useState<Granter[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [sortBy, setSortBy] = useState('name_asc');
const sortedGranters = useClientSort(granters, sortBy, SORT_CONFIGS);
const fetchGranters = useCallback(async () => {
try {
const res = await fetch('/api/granters', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setGranters(data.granters || []);
}
} catch (err) {
console.error('Failed to fetch granters:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchGranters(); }, [fetchGranters]);
const handleCreate = async (formData: Record<string, string>) => {
try {
const res = await fetch('/api/granters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
...formData,
funding_areas: formData.funding_areas ? formData.funding_areas.split(',').map(s => s.trim()).filter(Boolean) : [],
preference_tags: formData.preference_tags ? formData.preference_tags.split(',').map(s => s.trim()).filter(Boolean) : [],
annual_budget: formData.annual_budget ? parseFloat(formData.annual_budget) : null,
min_grant: formData.min_grant ? parseFloat(formData.min_grant) : null,
max_grant: formData.max_grant ? parseFloat(formData.max_grant) : null,
grants_per_year: formData.grants_per_year ? parseInt(formData.grants_per_year) : null,
}),
});
if (res.ok) {
setShowCreate(false);
await fetchGranters();
addToast('Granter added', 'success');
} else {
addToast('Failed to add granter', 'error');
}
} catch (err) {
console.error('Create granter error:', err);
addToast('Failed to add granter', 'error');
}
};
const fmtMoney = (val: string | null) => {
if (!val) return 'N/A';
const n = parseFloat(val);
if (n >= 1000000) return `$${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `$${(n / 1000).toFixed(0)}K`;
return `$${n.toLocaleString()}`;
};
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)' }}>
<Landmark size={20} style={{ color: '#60a5fa' }} />
Granters
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Foundations, corporations, and government funders
</p>
</div>
<div className="flex items-center gap-2">
<SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
<Plus size={14} />
Add Granter
</button>
</div>
</div>
{loading ? (
<SkeletonList count={4} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AnimatePresence>
{sortedGranters.map((g, i) => (
<motion.div key={g.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-4 flex flex-col gap-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold truncate" style={{ color: 'var(--color-text)' }}>{g.name}</h3>
{(g.city || g.state) && (
<div className="flex items-center gap-1 text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
<MapPin size={10} /> {[g.city, g.state].filter(Boolean).join(', ')}
</div>
)}
</div>
{g.website_url && (
<a href={g.website_url} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
<ExternalLink size={12} />
</a>
)}
</div>
{g.description && (
<p className="text-xs line-clamp-2" style={{ color: 'var(--color-text-muted)' }}>{g.description}</p>
)}
<div className="grid grid-cols-3 gap-2">
<div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<DollarSign size={12} style={{ color: '#22c55e', margin: '0 auto 2px' }} />
<div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{fmtMoney(g.annual_budget)}</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Budget</div>
</div>
<div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<DollarSign size={12} style={{ color: '#fbbf24', margin: '0 auto 2px' }} />
<div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>
{fmtMoney(g.min_grant)}-{fmtMoney(g.max_grant)}
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Range</div>
</div>
<div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<Calendar size={12} style={{ color: '#c084fc', margin: '0 auto 2px' }} />
<div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{g.grants_per_year ?? 'N/A'}</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Per Year</div>
</div>
</div>
{g.funding_areas && g.funding_areas.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{g.funding_areas.slice(0, 4).map((area: string) => (
<span key={area} className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', color: '#60a5fa', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
{area}
</span>
))}
</div>
)}
{g.preference_tags && g.preference_tags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{g.preference_tags.slice(0, 3).map((tag: string) => (
<span key={tag} className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border)' }}>
<Tag size={10} style={{ display: 'inline', marginRight: 2 }} />{tag}
</span>
))}
</div>
)}
</motion.div>
))}
</AnimatePresence>
</div>
)}
{!loading && granters.length === 0 && (
<div className="text-center py-12">
<Landmark size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
<p style={{ color: 'var(--color-text-muted)' }}>No granters yet. Add funding organizations to start matching.</p>
</div>
)}
{showCreate && <CreateGranterModal onClose={() => setShowCreate(false)} onSubmit={handleCreate} />}
</div>
);
}
function CreateGranterModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Record<string, string>) => void }) {
useEffect(() => {
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
const [form, setForm] = useState({ name: '', description: '', website_url: '', city: '', state: '', funding_areas: '', annual_budget: '', min_grant: '', max_grant: '', grants_per_year: '', preference_tags: '' });
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
setSubmitting(true);
await onSubmit(form);
setSubmitting(false);
};
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => 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)' }}>Add Granter</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)' }}>Name *</label>
<input className="input" value={form.name} onChange={set('name')} placeholder="Foundation name" />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Description</label>
<textarea className="input" rows={2} value={form.description} onChange={set('description')} placeholder="About this funder..." />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Website</label>
<input className="input" value={form.website_url} onChange={set('website_url')} placeholder="https://..." />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>City</label>
<input className="input" value={form.city} onChange={set('city')} />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>State</label>
<input className="input" value={form.state} onChange={set('state')} />
</div>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Funding Areas (comma-separated)</label>
<input className="input" value={form.funding_areas} onChange={set('funding_areas')} placeholder="education, health, environment" />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Annual Budget ($)</label>
<input className="input" type="number" value={form.annual_budget} onChange={set('annual_budget')} />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Min Grant ($)</label>
<input className="input" type="number" value={form.min_grant} onChange={set('min_grant')} />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Max Grant ($)</label>
<input className="input" type="number" value={form.max_grant} onChange={set('max_grant')} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Grants Per Year</label>
<input className="input" type="number" value={form.grants_per_year} onChange={set('grants_per_year')} />
</div>
<div>
<label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Preference Tags</label>
<input className="input" value={form.preference_tags} onChange={set('preference_tags')} placeholder="innovation, youth" />
</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.name || submitting} className="btn btn-primary">
{submitting ? <Loader2 size={14} className="animate-spin" /> : null}
Add Granter
</button>
</div>
</div>
</motion.div>
</div>
);
}