← back to Patty
components/grants/GrantsTab.tsx
550 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
DollarSign, Search, Sparkles, Loader2, ChevronDown, ChevronUp,
Bookmark, BookmarkCheck, ExternalLink, Calendar, Tag, Target,
AlertCircle, Clock, TrendingUp,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
import { SkeletonList } from '../Skeleton';
import { Grant, formatAmount, STATUS_COLORS, STATUS_LABELS } from './types';
/* ─── Constants ──────────────────────────────────────────────────────────── */
const PRIORITY_COLORS: Record<string, string> = {
high: '#ef4444',
medium: '#f59e0b',
low: '#6b7280',
};
/* ─── Component ──────────────────────────────────────────────────────────── */
export default function GrantsTab({
selectedId,
onSelectedConsumed,
}: {
selectedId?: string | null;
onSelectedConsumed?: () => void;
}) {
const { addToast } = useToast();
const [grants, setGrants] = useState<Grant[]>([]);
const [loading, setLoading] = useState(true);
const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
const [filter, setFilter] = useState('all');
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [expanded, setExpanded] = useState<string | null>(null);
const [discovering, setDiscovering] = useState(false);
/* ── Quick stats ─────────────────────────────────────────────────── */
const [dashStats, setDashStats] = useState<{
grant_count: number;
total_funding: number;
} | null>(null);
/* ── Fetch grants ────────────────────────────────────────────────── */
const fetchGrants = useCallback(async () => {
try {
const params = new URLSearchParams();
if (filter !== 'all') params.set('status', filter);
if (debouncedSearch) params.set('search', debouncedSearch);
const qs = params.toString() ? `?${params}` : '';
const res = await fetch(`/api/grants-proxy/grants${qs}`);
if (!res.ok) throw new Error('Failed to load grants');
const data = await res.json();
setGrants(data.rows || []);
setStatusCounts(data.statusCounts || {});
} catch (err) {
console.error('Grants fetch error:', err);
addToast('Failed to load grants', 'error');
} finally {
setLoading(false);
}
}, [filter, debouncedSearch, addToast]);
const fetchDashStats = useCallback(async () => {
try {
const res = await fetch('/api/grants-proxy/dashboard');
if (res.ok) {
const data = await res.json();
setDashStats({ grant_count: data.grant_count, total_funding: data.total_funding });
}
} catch { /* ignore */ }
}, []);
useEffect(() => {
fetchGrants();
fetchDashStats();
}, [fetchGrants, fetchDashStats]);
/* ── Cross-tab navigation ────────────────────────────────────────── */
useEffect(() => {
if (selectedId) {
setExpanded(selectedId);
onSelectedConsumed?.();
// scroll into view after render
setTimeout(() => {
document.getElementById(`grant-${selectedId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}, [selectedId, onSelectedConsumed]);
/* ── AI Discovery ────────────────────────────────────────────────── */
async function discoverGrants() {
setDiscovering(true);
try {
const res = await fetch('/api/grants-proxy/grants/discover', { method: 'POST' });
if (!res.ok) throw new Error('Discovery failed');
const data = await res.json();
addToast(`Discovered ${data.discovered || 0} new grants`, 'success');
fetchGrants();
fetchDashStats();
} catch (err) {
console.error('Discovery error:', err);
addToast('Grant discovery failed — check Grant app status', 'error');
} finally {
setDiscovering(false);
}
}
/* ── Actions ─────────────────────────────────────────────────────── */
async function toggleBookmark(grant: Grant) {
try {
const res = await fetch('/api/grants-proxy/grants', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: grant.id, is_bookmarked: !grant.is_bookmarked }),
});
if (!res.ok) throw new Error('Bookmark failed');
setGrants((prev) =>
prev.map((g) => (g.id === grant.id ? { ...g, is_bookmarked: !g.is_bookmarked } : g))
);
} catch {
addToast('Failed to update bookmark', 'error');
}
}
async function updateStatus(grantId: string, status: string) {
try {
const res = await fetch('/api/grants-proxy/grants', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: grantId, status }),
});
if (!res.ok) throw new Error('Status update failed');
addToast(`Status updated to ${STATUS_LABELS[status] || status}`, 'success');
fetchGrants();
} catch {
addToast('Failed to update status', 'error');
}
}
/* ─── Render ─────────────────────────────────────────────────────── */
if (loading) return <SkeletonList count={6} />;
const statusFilters = ['all', 'discovered', 'researching', 'preparing', 'submitted', 'awarded', 'declined', 'expired'];
return (
<div style={{ padding: 24, maxWidth: 1200 }}>
{/* ── Header ─────────────────────────────────────────────────── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20, flexWrap: 'wrap', gap: 12 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
Grants
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
{statusCounts.all ?? grants.length} total
</span>
</h2>
</div>
<button
onClick={discoverGrants}
disabled={discovering}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 16px', borderRadius: 8, border: 'none',
backgroundColor: '#059669', color: '#fff',
fontSize: 13, fontWeight: 600, cursor: discovering ? 'wait' : 'pointer',
opacity: discovering ? 0.7 : 1,
transition: 'opacity 0.15s',
}}
>
{discovering ? <Loader2 size={15} style={{ animation: 'spin 1s linear infinite' }} /> : <Sparkles size={15} />}
{discovering ? 'Discovering…' : 'Discover Grants'}
</button>
</div>
{/* ── Quick Stats ────────────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10, marginBottom: 20 }}>
{[
{ label: 'Total Grants', value: statusCounts.all ?? 0, icon: DollarSign, color: '#059669' },
{ label: 'Submitted', value: statusCounts.submitted ?? 0, icon: Target, color: '#06b6d4' },
{ label: 'Awarded', value: statusCounts.awarded ?? 0, icon: TrendingUp, color: '#22c55e' },
{ label: 'Total Funding', value: dashStats?.total_funding ? `$${dashStats.total_funding.toLocaleString()}` : '$0', icon: DollarSign, color: '#10b981' },
].map((s) => (
<div key={s.label} style={{
padding: '14px 16px', borderRadius: 10,
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
display: 'flex', alignItems: 'center', gap: 12,
}}>
<div style={{
width: 36, height: 36, borderRadius: 8,
backgroundColor: s.color + '18',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<s.icon size={18} style={{ color: s.color }} />
</div>
<div>
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 500 }}>{s.label}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>{s.value}</div>
</div>
</div>
))}
</div>
{/* ── Search ─────────────────────────────────────────────────── */}
<div style={{ marginBottom: 16, position: 'relative' }}>
<Search size={14} style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search grants by title, funder, or tags…"
style={{
width: '100%', padding: '8px 12px 8px 34px', borderRadius: 8,
border: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)',
color: 'var(--color-text)', fontSize: 13,
outline: 'none',
}}
/>
</div>
{/* ── Status Pipeline Tabs ───────────────────────────────────── */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20 }}>
{statusFilters.map((s) => {
const isActive = filter === s;
const color = s === 'all' ? '#7c3aed' : STATUS_COLORS[s] || '#6b7280';
const count = s === 'all' ? (statusCounts.all ?? grants.length) : (statusCounts[s] ?? 0);
return (
<button
key={s}
onClick={() => setFilter(isActive ? 'all' : s)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '5px 12px', borderRadius: 20, cursor: 'pointer',
border: isActive ? `2px solid ${color}` : '1px solid var(--color-border)',
backgroundColor: isActive ? color + '22' : 'var(--color-surface)',
color: isActive ? color : 'var(--color-text-muted)',
fontSize: 12, fontWeight: 500,
transition: 'all 0.15s',
}}
>
{s !== 'all' && (
<span style={{ width: 7, height: 7, borderRadius: '50%', backgroundColor: color }} />
)}
{s === 'all' ? 'All' : STATUS_LABELS[s] || s} ({count})
</button>
);
})}
</div>
{/* ── Grant Cards ────────────────────────────────────────────── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{grants.map((grant) => {
const isExpanded = expanded === grant.id;
return (
<div
key={grant.id}
id={`grant-${grant.id}`}
style={{
borderRadius: 10,
backgroundColor: 'var(--color-surface)',
border: isExpanded ? '1px solid #059669' : '1px solid var(--color-border)',
transition: 'border-color 0.15s',
overflow: 'hidden',
}}
>
{/* Card Header */}
<div
style={{
padding: '14px 16px', cursor: 'pointer',
display: 'flex', alignItems: 'flex-start', gap: 12,
}}
onClick={() => setExpanded(isExpanded ? null : grant.id)}
>
{/* Left: status dot + content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
backgroundColor: STATUS_COLORS[grant.status] || '#6b7280',
}} />
<span style={{
fontSize: 14, fontWeight: 600, color: 'var(--color-text)',
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
}}>
{grant.title}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 12, color: 'var(--color-text-muted)', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 500 }}>{grant.funder}</span>
<span style={{ color: '#059669', fontWeight: 600 }}>{formatAmount(grant.amount_min, grant.amount_max)}</span>
{grant.deadline && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Calendar size={11} />
{new Date(grant.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
)}
{grant.ai_fit_score != null && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Sparkles size={11} style={{ color: '#f59e0b' }} />
{Math.round(grant.ai_fit_score * 100)}% fit
</span>
)}
</div>
</div>
{/* Right: badges + chevron */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span style={{
fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 4,
backgroundColor: (STATUS_COLORS[grant.status] || '#6b7280') + '22',
color: STATUS_COLORS[grant.status] || '#6b7280',
textTransform: 'uppercase',
}}>
{STATUS_LABELS[grant.status] || grant.status}
</span>
<span style={{
fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 4,
backgroundColor: (PRIORITY_COLORS[grant.priority] || '#6b7280') + '18',
color: PRIORITY_COLORS[grant.priority] || '#6b7280',
textTransform: 'uppercase',
}}>
{grant.priority}
</span>
<button
onClick={(e) => { e.stopPropagation(); toggleBookmark(grant); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2 }}
title={grant.is_bookmarked ? 'Remove bookmark' : 'Bookmark'}
>
{grant.is_bookmarked
? <BookmarkCheck size={16} style={{ color: '#f59e0b' }} />
: <Bookmark size={16} style={{ color: 'var(--color-text-muted)' }} />
}
</button>
{isExpanded ? <ChevronUp size={16} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={16} style={{ color: 'var(--color-text-muted)' }} />}
</div>
</div>
{/* Expanded Detail */}
{isExpanded && (
<div style={{ padding: '0 16px 16px', borderTop: '1px solid var(--color-border)' }}>
{/* AI Fit Score Bar */}
{grant.ai_fit_score != null && (
<div style={{ marginTop: 12, marginBottom: 12 }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>
AI Fit Score
</div>
<div style={{
height: 6, borderRadius: 3,
backgroundColor: 'var(--color-border)',
overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 3,
width: `${Math.round(grant.ai_fit_score * 100)}%`,
backgroundColor: grant.ai_fit_score >= 0.7 ? '#22c55e' : grant.ai_fit_score >= 0.4 ? '#f59e0b' : '#ef4444',
transition: 'width 0.3s ease',
}} />
</div>
</div>
)}
{/* AI Suggestion */}
{grant.ai_suggestion && (
<div style={{
padding: '10px 12px', borderRadius: 8, marginBottom: 12,
backgroundColor: '#059669' + '12',
border: '1px solid #059669' + '30',
fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.5,
}}>
<strong style={{ color: '#10b981' }}>AI Suggestion:</strong> {grant.ai_suggestion}
</div>
)}
{/* Description */}
{grant.description && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Description
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
{grant.description}
</div>
</div>
)}
{/* Eligibility */}
{grant.eligibility && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Eligibility
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
{grant.eligibility}
</div>
</div>
)}
{/* Focus Areas */}
{grant.focus_areas?.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Focus Areas
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{grant.focus_areas.map((area) => (
<span key={area} style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 500,
backgroundColor: '#059669' + '18', color: '#10b981',
}}>
{area}
</span>
))}
</div>
</div>
)}
{/* Tags */}
{grant.tags?.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{grant.tags.map((tag) => (
<span key={tag} style={{
display: 'flex', alignItems: 'center', gap: 3,
padding: '2px 8px', borderRadius: 4, fontSize: 11,
backgroundColor: 'var(--color-bg)', color: 'var(--color-text-muted)',
}}>
<Tag size={9} /> {tag}
</span>
))}
</div>
</div>
)}
{/* Notes */}
{grant.notes && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Notes
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.5, fontStyle: 'italic' }}>
{grant.notes}
</div>
</div>
)}
{/* Meta row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 12, flexWrap: 'wrap' }}>
{grant.cycle && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Clock size={11} /> Cycle: {grant.cycle}
</span>
)}
<span>
Added {new Date(grant.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
{/* Actions row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{grant.application_url && (
<a
href={grant.application_url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '6px 12px', borderRadius: 6,
backgroundColor: '#059669', color: '#fff',
fontSize: 12, fontWeight: 600, textDecoration: 'none',
}}
>
<ExternalLink size={12} /> Apply
</a>
)}
{grant.funder_url && (
<a
href={grant.funder_url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '6px 12px', borderRadius: 6,
border: '1px solid var(--color-border)',
backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
fontSize: 12, fontWeight: 500, textDecoration: 'none',
}}
>
<ExternalLink size={12} /> Funder Site
</a>
)}
{/* Status change dropdown */}
<select
value={grant.status}
onChange={(e) => updateStatus(grant.id, e.target.value)}
style={{
padding: '6px 10px', borderRadius: 6, fontSize: 12,
border: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)',
color: 'var(--color-text)',
cursor: 'pointer',
}}
>
{Object.entries(STATUS_LABELS).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
</div>
)}
</div>
);
})}
</div>
{/* ── Empty state ────────────────────────────────────────────── */}
{grants.length === 0 && !loading && (
<div style={{
padding: 48, textAlign: 'center',
color: 'var(--color-text-muted)',
}}>
<DollarSign size={40} style={{ marginBottom: 12, opacity: 0.3 }} />
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>No grants found</div>
<div style={{ fontSize: 13, marginBottom: 16 }}>
{filter !== 'all' ? 'Try a different status filter.' : 'Click "Discover Grants" to find funding opportunities with AI.'}
</div>
{filter === 'all' && (
<button
onClick={discoverGrants}
disabled={discovering}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
backgroundColor: '#059669', color: '#fff',
fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}
>
<Sparkles size={14} style={{ marginRight: 6, verticalAlign: 'middle' }} />
Discover Grants
</button>
)}
</div>
)}
</div>
);
}