← back to Norma
components/agents/GrantFinder.tsx
537 lines
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search, Building2, DollarSign, Calendar, ChevronDown, ChevronUp,
ExternalLink, Sparkles, Filter, Tag, AlertCircle, Loader2,
Trophy, TrendingUp, Clock, FileText,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
interface Organization {
id: string;
name: string;
city: string | null;
state: string | null;
}
interface GrantResult {
id: string;
source: string;
title: string;
funder: string;
funder_url: string | null;
amount_min: number | null;
amount_max: number | null;
description: string | null;
eligibility: string | null;
focus_areas: string[];
deadline: string | null;
application_url: string | null;
status: string;
match_score: number;
}
const GRANT_TYPES = [
{ value: '', label: 'All Types' },
{ value: 'federal', label: 'Federal' },
{ value: 'state', label: 'State' },
{ value: 'foundation', label: 'Foundation' },
{ value: 'corporate', label: 'Corporate' },
];
const ISSUE_AREAS = [
'', 'education', 'advocacy', 'healthcare', 'housing', 'workforce',
'economic justice', 'racial equity', 'climate', 'food security', 'mental health',
];
interface GrantFinderProps {
selectedOrgId: string;
onOrgChange: (id: string) => void;
}
export default function GrantFinder({ selectedOrgId, onOrgChange }: GrantFinderProps) {
const { addToast } = useToast();
const [orgs, setOrgs] = useState<Organization[]>([]);
const [grants, setGrants] = useState<GrantResult[]>([]);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [generatingProposal, setGeneratingProposal] = useState<string | null>(null);
const [proposalText, setProposalText] = useState<Record<string, string>>({});
// Filters
const [grantType, setGrantType] = useState('');
const [issueArea, setIssueArea] = useState('');
const [budgetMin, setBudgetMin] = useState('');
const [budgetMax, setBudgetMax] = useState('');
// Load organizations
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/orgs?limit=200&sort=name&sort_dir=asc');
if (res.ok) {
const data = await res.json();
setOrgs(data.rows || []);
}
} catch {}
})();
}, []);
async function handleSearch() {
if (!selectedOrgId) {
addToast('Please select an organization first', 'error');
return;
}
setLoading(true);
setGrants([]);
setSearched(true);
try {
const params = new URLSearchParams({ org_id: selectedOrgId });
if (grantType) params.set('grant_type', grantType);
if (issueArea) params.set('issue_area', issueArea);
if (budgetMin) params.set('budget_min', budgetMin);
if (budgetMax) params.set('budget_max', budgetMax);
const res = await fetch(`/api/agents/grants/match?${params}`);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Search failed');
}
const data = await res.json();
setGrants(data.grants || []);
addToast(`Found ${(data.grants || []).length} matching grants`, 'success');
} catch (err) {
addToast((err as Error).message || 'Failed to search grants', 'error');
}
setLoading(false);
}
async function handleGenerateProposal(grant: GrantResult) {
setGeneratingProposal(grant.id);
try {
const org = orgs.find(o => o.id === selectedOrgId);
const prompt = `Generate a concise grant proposal outline for:
Organization: ${org?.name || 'Unknown'}
Grant: ${grant.title}
Funder: ${grant.funder}
Amount Range: $${(grant.amount_min || 0).toLocaleString()} - $${(grant.amount_max || 0).toLocaleString()}
Grant Focus: ${(grant.focus_areas || []).join(', ')}
Deadline: ${grant.deadline ? new Date(grant.deadline).toLocaleDateString() : 'N/A'}
Create a structured outline with:
1. Executive Summary (2-3 sentences)
2. Statement of Need
3. Project Description
4. Goals & Objectives (3-5 SMART goals)
5. Budget Overview
6. Evaluation Plan
7. Sustainability Plan
Keep it concise but professional.`;
const res = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, temperature: 0.7, maxOutputTokens: 2048 }),
});
if (!res.ok) throw new Error('AI generation failed');
const data = await res.json();
const text = data?.text || 'Failed to generate proposal.';
setProposalText(prev => ({ ...prev, [grant.id]: text }));
addToast('Proposal outline generated', 'success');
} catch (err) {
addToast('Failed to generate proposal outline', 'error');
}
setGeneratingProposal(null);
}
function formatAmount(min: number | null, max: number | null): string {
if (!min && !max) return 'Varies';
const fmt = (n: number) => {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`;
return `$${n.toLocaleString()}`;
};
if (min && max) return `${fmt(min)} - ${fmt(max)}`;
if (max) return `Up to ${fmt(max)}`;
return `From ${fmt(min!)}`;
}
function getScoreColor(score: number): string {
if (score >= 70) return '#22c55e';
if (score >= 40) return '#f59e0b';
return '#ef4444';
}
function getScoreLabel(score: number): string {
if (score >= 70) return 'Strong Match';
if (score >= 40) return 'Moderate Match';
return 'Low Match';
}
return (
<div style={{ padding: 24, maxWidth: 1100, margin: '0 auto' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{
width: 40, height: 40, borderRadius: 12,
backgroundColor: 'rgba(245,158,11,0.12)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Search size={20} style={{ color: '#f59e0b' }} />
</div>
<div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
AI Grant Finder
</h1>
<p style={{ fontSize: 13, color: 'var(--color-text-muted)', margin: 0 }}>
Find and match grants to your organization's mission
</p>
</div>
</div>
{/* Filters */}
<div className="card" style={{ padding: 16, marginBottom: 20 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 12 }}>
{/* Organization */}
<div style={{ flex: '1 1 220px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<Building2 size={12} /> Organization
</label>
<select className="input" value={selectedOrgId} onChange={e => onOrgChange(e.target.value)}>
<option value="">-- Select Org --</option>
{orgs.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
{/* Grant Type */}
<div style={{ flex: '0 1 150px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<Filter size={12} /> Type
</label>
<select className="input" value={grantType} onChange={e => setGrantType(e.target.value)}>
{GRANT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
{/* Issue Area */}
<div style={{ flex: '0 1 160px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<Tag size={12} /> Issue Area
</label>
<select className="input" value={issueArea} onChange={e => setIssueArea(e.target.value)}>
<option value="">All Areas</option>
{ISSUE_AREAS.filter(Boolean).map(a => <option key={a} value={a}>{a.charAt(0).toUpperCase() + a.slice(1)}</option>)}
</select>
</div>
{/* Budget Range */}
<div style={{ flex: '0 1 120px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<DollarSign size={12} /> Min
</label>
<input
className="input"
type="number"
value={budgetMin}
onChange={e => setBudgetMin(e.target.value)}
placeholder="0"
min={0}
/>
</div>
<div style={{ flex: '0 1 120px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<DollarSign size={12} /> Max
</label>
<input
className="input"
type="number"
value={budgetMax}
onChange={e => setBudgetMax(e.target.value)}
placeholder="No limit"
min={0}
/>
</div>
{/* Search Button */}
<button
className="btn btn-primary"
onClick={handleSearch}
disabled={loading || !selectedOrgId}
style={{ height: 38 }}
>
{loading ? (
<>
<span className="spinner" style={{ width: 14, height: 14 }} />
Searching...
</>
) : (
<>
<Search size={14} />
Find Grants
</>
)}
</button>
</div>
</div>
{/* Results */}
{loading && (
<div style={{ textAlign: 'center', padding: 48 }}>
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto 16px' }} />
<p style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>Matching grants to your organization...</p>
</div>
)}
{!loading && searched && grants.length === 0 && (
<div className="card" style={{ padding: 48, textAlign: 'center' }}>
<AlertCircle size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px', display: 'block' }} />
<h3 style={{ fontSize: 16, fontWeight: 600, color: 'var(--color-text)', margin: '0 0 8px' }}>
No matching grants found
</h3>
<p style={{ fontSize: 13, color: 'var(--color-text-muted)', maxWidth: 400, margin: '0 auto' }}>
Try adjusting your filters or broadening your search criteria.
</p>
</div>
)}
{!loading && grants.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
{grants.length} grants found, sorted by match score
</span>
</div>
{grants.map((grant, idx) => (
<motion.div
key={grant.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.03, duration: 0.2 }}
>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* Main Row */}
<div
style={{
padding: 16,
display: 'flex', alignItems: 'flex-start', gap: 16,
cursor: 'pointer',
}}
onClick={() => setExpandedId(expandedId === grant.id ? null : grant.id)}
>
{/* Score Badge */}
<div style={{
width: 52, height: 52, borderRadius: 12, flexShrink: 0,
backgroundColor: `${getScoreColor(grant.match_score)}15`,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
}}>
<span style={{ fontSize: 18, fontWeight: 700, color: getScoreColor(grant.match_score), lineHeight: 1 }}>
{grant.match_score}
</span>
<span style={{ fontSize: 8, fontWeight: 600, color: getScoreColor(grant.match_score), textTransform: 'uppercase' }}>
match
</span>
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{grant.title}
</h3>
<span className={`badge ${grant.source === 'federal' ? 'lane-b' : 'lane-c'}`} style={{ flexShrink: 0 }}>
{grant.source}
</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, fontSize: 12, color: 'var(--color-text-secondary)' }}>
{(grant.application_url || grant.funder_url) ? (
<a
href={grant.application_url || grant.funder_url || '#'}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--color-primary)', textDecoration: 'none' }}
>
<Building2 size={12} /> {grant.funder} <ExternalLink size={10} />
</a>
) : (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Building2 size={12} /> {grant.funder}
</span>
)}
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<DollarSign size={12} /> {formatAmount(grant.amount_min, grant.amount_max)}
</span>
{grant.deadline && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Calendar size={12} />
{new Date(grant.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span style={{
fontSize: 11, fontWeight: 600, padding: '3px 10px', borderRadius: 10,
backgroundColor: `${getScoreColor(grant.match_score)}15`,
color: getScoreColor(grant.match_score),
}}>
{getScoreLabel(grant.match_score)}
</span>
{expandedId === grant.id ? <ChevronUp size={16} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={16} style={{ color: 'var(--color-text-muted)' }} />}
</div>
</div>
{/* Expanded Details */}
<AnimatePresence>
{expandedId === grant.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
style={{ overflow: 'hidden' }}
>
<div style={{
padding: '0 16px 16px',
borderTop: '1px solid var(--color-border)',
paddingTop: 16,
}}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
{/* Description */}
<div>
<h4 style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', margin: '0 0 6px' }}>
Description
</h4>
<p style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.6, margin: 0 }}>
{grant.description ? (grant.description.length > 400 ? grant.description.slice(0, 400) + '...' : grant.description) : 'No description available.'}
</p>
</div>
{/* Eligibility */}
<div>
<h4 style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', margin: '0 0 6px' }}>
Eligibility
</h4>
<p style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.6, margin: 0 }}>
{grant.eligibility ? (grant.eligibility.length > 400 ? grant.eligibility.slice(0, 400) + '...' : grant.eligibility) : 'See application for details.'}
</p>
</div>
</div>
{/* Focus Areas */}
{grant.focus_areas && grant.focus_areas.length > 0 && (
<div style={{ marginBottom: 16 }}>
<h4 style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', margin: '0 0 6px' }}>
Focus Areas
</h4>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{grant.focus_areas.map((area, i) => (
<span key={i} style={{
fontSize: 11, padding: '3px 10px', borderRadius: 10,
backgroundColor: 'rgba(99,102,241,0.1)',
color: '#34d399', fontWeight: 500,
}}>
{area}
</span>
))}
</div>
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
className="btn btn-primary btn-sm"
onClick={(e) => { e.stopPropagation(); handleGenerateProposal(grant); }}
disabled={generatingProposal === grant.id}
>
{generatingProposal === grant.id ? (
<>
<span className="spinner" style={{ width: 12, height: 12 }} />
Generating...
</>
) : (
<>
<Sparkles size={14} />
Generate Proposal Outline
</>
)}
</button>
{(grant.application_url || grant.funder_url) && (
<a
href={grant.application_url || grant.funder_url || '#'}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary btn-sm"
onClick={e => e.stopPropagation()}
>
<ExternalLink size={14} />
{grant.application_url ? 'Apply' : 'View Funder'}
</a>
)}
</div>
{/* Proposal Outline */}
{proposalText[grant.id] && (
<div style={{
marginTop: 16, padding: 16, borderRadius: 8,
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}>
<h4 style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)', margin: '0 0 8px', display: 'flex', alignItems: 'center', gap: 6 }}>
<FileText size={14} style={{ color: 'var(--color-primary)' }} />
Proposal Outline
</h4>
<pre style={{
fontSize: 12, color: 'var(--color-text-secondary)',
lineHeight: 1.6, whiteSpace: 'pre-wrap', margin: 0,
fontFamily: 'inherit',
}}>
{proposalText[grant.id]}
</pre>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
))}
</div>
)}
{/* Empty state before search */}
{!searched && !loading && (
<div className="card" style={{ padding: 48, textAlign: 'center' }}>
<div style={{
width: 56, height: 56, borderRadius: 16, margin: '0 auto 16px',
backgroundColor: 'rgba(245,158,11,0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Trophy size={26} style={{ color: '#f59e0b', opacity: 0.6 }} />
</div>
<h3 style={{ fontSize: 16, fontWeight: 600, color: 'var(--color-text)', margin: '0 0 8px' }}>
Find Your Perfect Grant Match
</h3>
<p style={{ fontSize: 13, color: 'var(--color-text-muted)', maxWidth: 400, margin: '0 auto' }}>
Select an organization and configure your filters. The AI will score and rank grants
based on mission alignment, issue overlap, and focus populations.
</p>
</div>
)}
</div>
);
}