← back to Grant
components/grants/GrantsTab.tsx
665 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { SkeletonList } from '../Skeleton';
import {
Award,
Search,
Plus,
Sparkles,
ChevronDown,
ChevronUp,
Bookmark,
BookmarkCheck,
Calendar,
DollarSign,
FileText,
Loader2,
ExternalLink,
Clock,
Send,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useAuth } from '../AuthProvider';
import { useDebounce } from '@/hooks/useDebounce';
import AddGrantWizard from './AddGrantWizard';
import ProposalModal from './ProposalModal';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
const GRANT_SORT_OPTIONS = [
{ value: 'deadline_asc', label: 'Deadline Soonest' },
{ value: 'fit_desc', label: 'Best Fit' },
{ value: 'amount_desc', label: 'Highest Amount' },
{ value: 'status', label: 'Status A-Z' },
{ value: 'date_desc', label: 'Newest First' },
];
const GRANT_SORT_CONFIGS: Record<string, SortConfig> = {
deadline_asc: { key: 'deadline', direction: 'asc', type: 'date' },
fit_desc: { key: 'ai_fit_score', direction: 'desc', type: 'number' },
amount_desc: { key: 'amount_max', direction: 'desc', type: 'number' },
status: { key: 'status', direction: 'asc', type: 'string' },
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
};
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface Grant {
id: 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[];
application_url: string | null;
deadline: string | null;
cycle: string | null;
ai_fit_score: number | null;
ai_suggestion: string | null;
status: string;
priority: string;
notes: string | null;
tags: string[];
is_bookmarked: boolean;
created_at: string;
[key: string]: unknown;
}
type StatusFilter = 'all' | 'discovered' | 'researching' | 'preparing' | 'submitted' | 'awarded';
const STATUS_OPTIONS: { id: StatusFilter; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'discovered', label: 'Discovered' },
{ id: 'researching', label: 'Researching' },
{ id: 'preparing', label: 'Preparing' },
{ id: 'submitted', label: 'Submitted' },
{ id: 'awarded', label: 'Awarded' },
];
const PRIORITY_COLORS: Record<string, string> = {
high: '#ef4444',
medium: '#f59e0b',
low: '#6b7280',
};
const STATUS_COLORS: Record<string, { bg: string; text: string; border: string }> = {
discovered: { bg: 'rgba(59,130,246,0.15)', text: '#60a5fa', border: 'rgba(59,130,246,0.3)' },
researching: { bg: 'rgba(168,85,247,0.15)', text: '#c084fc', border: 'rgba(168,85,247,0.3)' },
preparing: { bg: 'rgba(245,158,11,0.15)', text: '#fbbf24', border: 'rgba(245,158,11,0.3)' },
submitted: { bg: 'rgba(5,150,105,0.15)', text: '#34d399', border: 'rgba(5,150,105,0.3)' },
awarded: { bg: 'rgba(34,197,94,0.15)', text: '#22c55e', border: 'rgba(34,197,94,0.3)' },
declined: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444', border: 'rgba(239,68,68,0.3)' },
expired: { bg: 'rgba(107,127,117,0.15)', text: '#6b7f75', border: 'rgba(107,127,117,0.3)' },
};
const QUICK_APPLY_STATUSES = ['discovered', 'researching', 'preparing'];
/* ─── Helpers ────────────────────────────────────────────────────────────── */
function formatCurrency(n: number | null): string {
if (n == null) return 'N/A';
return '$' + n.toLocaleString('en-US', { maximumFractionDigits: 0 });
}
function formatDate(d: string | null): string {
if (!d) return 'No deadline';
return new Date(d).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function daysUntil(d: string | null): number | null {
if (!d) return null;
const diff = new Date(d).getTime() - Date.now();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
/* ─── Deadline Badge ─────────────────────────────────────────────────────── */
function DeadlineBadge({ deadline }: { deadline: string | null }) {
if (!deadline) return null;
const days = Math.ceil((new Date(deadline).getTime() - Date.now()) / 86400000);
if (days < 0)
return (
<span
style={{
fontSize: '0.6875rem',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 6,
backgroundColor: 'rgba(239,68,68,0.15)',
color: '#f87171',
border: '1px solid rgba(239,68,68,0.3)',
}}
>
Overdue
</span>
);
if (days <= 7)
return (
<span
style={{
fontSize: '0.6875rem',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 6,
backgroundColor: 'rgba(239,68,68,0.15)',
color: '#f87171',
border: '1px solid rgba(239,68,68,0.3)',
animation: 'pulse 2s infinite',
}}
>
{days}d left
</span>
);
if (days <= 30)
return (
<span
style={{
fontSize: '0.6875rem',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 6,
backgroundColor: 'rgba(245,158,11,0.15)',
color: '#fbbf24',
border: '1px solid rgba(245,158,11,0.3)',
}}
>
{days}d left
</span>
);
return (
<span
style={{
fontSize: '0.6875rem',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 6,
backgroundColor: 'rgba(16,185,129,0.15)',
color: '#34d399',
border: '1px solid rgba(16,185,129,0.3)',
}}
>
{days}d left
</span>
);
}
/* ─── Main Component ─────────────────────────────────────────────────────── */
export default function GrantsTab() {
const { addToast } = useToast();
const { showAdmin, canWrite } = useAuth();
const [grants, setGrants] = useState<Grant[]>([]);
const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true);
const [discovering, setDiscovering] = useState(false);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [proposalGrant, setProposalGrant] = useState<Grant | null>(null);
const [sortBy, setSortBy] = useState('deadline_asc');
const sortedGrants = useClientSort(grants, sortBy, GRANT_SORT_CONFIGS);
const fetchGrants = useCallback(async () => {
try {
const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
if (debouncedSearch) params.set('search', debouncedSearch);
const res = await fetch(`/api/grants?${params}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setGrants(data.rows || []);
setStatusCounts(data.statusCounts || {});
}
} catch {
// fetch error handled by loading state
} finally {
setLoading(false);
}
}, [statusFilter, debouncedSearch]);
useEffect(() => {
fetchGrants();
}, [fetchGrants]);
const handleDiscover = async () => {
setDiscovering(true);
try {
const res = await fetch('/api/grants/discover', {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
await fetchGrants();
addToast('New grants discovered', 'success');
} else {
addToast('Discovery failed', 'error');
}
} catch {
addToast('Discovery failed', 'error');
} finally {
setDiscovering(false);
}
};
const handleBookmark = async (id: string, current: boolean) => {
try {
await fetch('/api/grants', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ id, is_bookmarked: !current }),
});
setGrants((prev) =>
prev.map((g) => (g.id === id ? { ...g, is_bookmarked: !current } : g)),
);
addToast(current ? 'Bookmark removed' : 'Bookmarked', 'success');
} catch {
addToast('Bookmark failed', 'error');
}
};
const handleStatusChange = async (id: string, newStatus: string) => {
try {
await fetch('/api/grants', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ id, status: newStatus }),
});
// Update local state immediately
setGrants((prev) =>
prev.map((g) => (g.id === id ? { ...g, status: newStatus } : g)),
);
addToast(`Status updated to ${newStatus}`, 'success');
} catch {
addToast('Status update failed', 'error');
}
};
const handleProposalStatusChange = (id: string, newStatus: string) => {
setGrants((prev) =>
prev.map((g) => (g.id === id ? { ...g, status: newStatus } : g)),
);
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>
Grant Discovery & Tracking
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Find, track, and apply for grant opportunities matched to your organization.
</p>
</div>
<div className="flex gap-2">
{showAdmin && (
<button
className="btn btn-secondary"
onClick={handleDiscover}
disabled={discovering}
>
{discovering ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Sparkles size={15} />
)}
{discovering ? 'Discovering...' : 'AI Discover Grants'}
</button>
)}
{canWrite && (
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
<Plus size={15} />
Add Grant
</button>
)}
</div>
</div>
{/* Search */}
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2"
style={{ color: 'var(--color-text-muted)' }}
/>
<input
type="text"
className="input"
placeholder="Search grants by name, funder, or keywords..."
aria-label="Search"
style={{ paddingLeft: '2.25rem' }}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Sort + Status Filter Pills */}
<div className="flex gap-2 flex-wrap items-center">
<SortDropdown options={GRANT_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
{STATUS_OPTIONS.map(({ id, label }) => {
const count = statusCounts[id] ?? 0;
const isActive = statusFilter === id;
return (
<button
key={id}
className="btn btn-sm"
onClick={() => setStatusFilter(id)}
style={{
backgroundColor: isActive ? 'rgba(5,150,105,0.12)' : 'var(--color-surface-el)',
color: isActive ? '#34d399' : 'var(--color-text-secondary)',
border: isActive
? '1px solid rgba(5,150,105,0.3)'
: '1px solid var(--color-border)',
}}
>
{label}
{count > 0 && (
<span
className="ml-1 text-xs"
style={{ opacity: 0.7 }}
>
({count})
</span>
)}
</button>
);
})}
</div>
{/* Grant Cards */}
{loading ? (
<SkeletonList count={4} />
) : sortedGrants.length === 0 ? (
<div className="card">
<div className="flex flex-col items-center justify-center py-16 text-center">
<div
className="w-14 h-14 rounded-xl flex items-center justify-center mb-4"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<Award size={24} style={{ color: 'var(--color-text-muted)' }} />
</div>
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
No grants found
</h3>
<p className="text-sm max-w-md" style={{ color: 'var(--color-text-muted)' }}>
Click "AI Discover Grants" to find funding opportunities matched to your organization.
</p>
</div>
</div>
) : (
<div className="space-y-3">
{sortedGrants.map((grant) => {
const isExpanded = expandedId === grant.id;
const statusStyle = STATUS_COLORS[grant.status] || STATUS_COLORS.discovered;
const fitPct = grant.ai_fit_score != null ? Math.round(grant.ai_fit_score * 100) : null;
return (
<div key={grant.id} className="card" style={{ padding: 0 }}>
{/* Main row */}
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3
className="text-sm font-semibold truncate"
style={{ color: 'var(--color-text)' }}
>
{grant.title}
</h3>
<span
className="badge"
style={{
backgroundColor: statusStyle.bg,
color: statusStyle.text,
border: `1px solid ${statusStyle.border}`,
}}
>
{grant.status}
</span>
<span
className="badge"
style={{
backgroundColor: `${PRIORITY_COLORS[grant.priority]}18`,
color: PRIORITY_COLORS[grant.priority],
border: `1px solid ${PRIORITY_COLORS[grant.priority]}40`,
}}
>
{grant.priority}
</span>
</div>
<p className="text-xs mb-2" style={{ color: 'var(--color-text-secondary)' }}>
{grant.funder}
</p>
<div className="flex items-center gap-4 flex-wrap text-xs" style={{ color: 'var(--color-text-muted)' }}>
<span className="flex items-center gap-1">
<DollarSign size={12} />
{grant.amount_min || grant.amount_max
? `${formatCurrency(grant.amount_min)} - ${formatCurrency(grant.amount_max)}`
: 'Amount TBD'}
</span>
<span className="flex items-center gap-1">
<Calendar size={12} />
{formatDate(grant.deadline)}
<DeadlineBadge deadline={grant.deadline} />
</span>
{fitPct != null && (
<span className="flex items-center gap-1">
<Sparkles size={12} style={{ color: '#34d399' }} />
<span style={{ color: fitPct >= 70 ? '#34d399' : fitPct >= 40 ? '#fbbf24' : '#6b7f75' }}>
{fitPct}% fit
</span>
</span>
)}
{grant.cycle && (
<span className="flex items-center gap-1">
<Clock size={12} />
{grant.cycle}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Quick Apply */}
{QUICK_APPLY_STATUSES.includes(grant.status) && (
<button
className="btn btn-ghost btn-sm"
onClick={() => setProposalGrant(grant)}
title="Quick Apply"
aria-label={`Quick apply for ${grant.title}`}
>
<Send size={14} style={{ color: '#059669' }} />
</button>
)}
<button
className="btn btn-ghost btn-sm"
onClick={() => handleBookmark(grant.id, grant.is_bookmarked)}
title={grant.is_bookmarked ? 'Remove bookmark' : 'Bookmark'}
aria-label={grant.is_bookmarked ? `Remove bookmark from ${grant.title}` : `Bookmark ${grant.title}`}
>
{grant.is_bookmarked ? (
<BookmarkCheck size={16} style={{ color: '#f59e0b' }} />
) : (
<Bookmark size={16} />
)}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setExpandedId(isExpanded ? null : grant.id)}
aria-label={isExpanded ? `Collapse ${grant.title}` : `Expand ${grant.title}`}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
</div>
{/* Tags */}
{grant.tags && grant.tags.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{grant.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-0.5 rounded-full"
style={{
backgroundColor: 'rgba(5,150,105,0.1)',
color: '#34d399',
border: '1px solid rgba(5,150,105,0.2)',
}}
>
{tag}
</span>
))}
</div>
)}
</div>
{/* Expanded content */}
{isExpanded && (
<div
className="px-4 pb-4 space-y-3"
style={{ borderTop: '1px solid var(--color-border)' }}
>
<div className="pt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
{grant.description && (
<div>
<h4 className="text-xs font-semibold mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Description
</h4>
<p className="text-xs leading-relaxed" style={{ color: 'var(--color-text-muted)' }}>
{grant.description}
</p>
</div>
)}
{grant.eligibility && (
<div>
<h4 className="text-xs font-semibold mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Eligibility
</h4>
<p className="text-xs leading-relaxed" style={{ color: 'var(--color-text-muted)' }}>
{grant.eligibility}
</p>
</div>
)}
</div>
{grant.ai_suggestion && (
<div
className="p-3 rounded-lg"
style={{
backgroundColor: 'rgba(5,150,105,0.06)',
border: '1px solid rgba(5,150,105,0.15)',
}}
>
<h4 className="text-xs font-semibold mb-1 flex items-center gap-1" style={{ color: '#34d399' }}>
<Sparkles size={12} />
AI Suggestion
</h4>
<p className="text-xs leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
{grant.ai_suggestion}
</p>
</div>
)}
{/* AI Fit Score Bar */}
{fitPct != null && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
AI Fit Score
</span>
<span className="text-xs font-bold" style={{ color: fitPct >= 70 ? '#34d399' : fitPct >= 40 ? '#fbbf24' : '#6b7f75' }}>
{fitPct}%
</span>
</div>
<div
className="h-1.5 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--color-surface-el)' }}
>
<div
className="h-full rounded-full transition-all"
style={{
width: `${fitPct}%`,
backgroundColor: fitPct >= 70 ? '#34d399' : fitPct >= 40 ? '#fbbf24' : '#6b7f75',
}}
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2 flex-wrap pt-1">
<button
className="btn btn-primary btn-sm"
onClick={() => setProposalGrant(grant)}
>
<FileText size={14} />
Write Proposal
</button>
{grant.application_url && (
<a
href={grant.application_url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary btn-sm"
>
<ExternalLink size={14} />
Apply
</a>
)}
<select
className="input"
style={{ width: 'auto', padding: '0.25rem 0.5rem', fontSize: '0.8125rem' }}
value={grant.status}
onChange={(e) => handleStatusChange(grant.id, e.target.value)}
aria-label={`Status for ${grant.title}`}
>
<option value="discovered">Discovered</option>
<option value="researching">Researching</option>
<option value="preparing">Preparing</option>
<option value="submitted">Submitted</option>
<option value="awarded">Awarded</option>
<option value="declined">Declined</option>
<option value="expired">Expired</option>
</select>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* Add Grant Wizard */}
{showAddModal && (
<AddGrantWizard onClose={() => setShowAddModal(false)} onCreated={fetchGrants} />
)}
{/* Proposal Modal */}
{proposalGrant && (
<ProposalModal
grant={proposalGrant}
onClose={() => setProposalGrant(null)}
onStatusChange={handleProposalStatusChange}
/>
)}
</div>
);
}