← back to Grant
app/river/RiverTable.tsx
142 lines
'use client';
/**
* RiverTable — sortable list view of the same grants the river renders.
*
* 2026-05-06 (tick 52): client component with click-to-sort column headers.
* No external dep, just useState for sort state.
*/
import { useState, useMemo } from 'react';
type Grant = {
id: string;
number?: string;
title?: string;
agencyCode?: string;
agency?: string;
openDate?: string;
closeDate?: string;
oppStatus?: string;
cfdaList?: string[];
};
type SortKey = 'title' | 'agency' | 'openDate' | 'closeDate' | 'oppStatus' | 'cfda';
type SortDir = 'asc' | 'desc';
function parseDate(s: string | undefined): number {
if (!s) return Number.MAX_SAFE_INTEGER;
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (!m) return Number.MAX_SAFE_INTEGER;
return new Date(`${m[3]}-${m[1]}-${m[2]}T00:00:00Z`).getTime();
}
function compare(a: Grant, b: Grant, key: SortKey, dir: SortDir): number {
let av: string | number = '';
let bv: string | number = '';
switch (key) {
case 'title': av = (a.title || '').toLowerCase(); bv = (b.title || '').toLowerCase(); break;
case 'agency': av = (a.agency || a.agencyCode || '').toLowerCase(); bv = (b.agency || b.agencyCode || '').toLowerCase(); break;
case 'openDate': av = parseDate(a.openDate); bv = parseDate(b.openDate); break;
case 'closeDate': av = parseDate(a.closeDate); bv = parseDate(b.closeDate); break;
case 'oppStatus': av = a.oppStatus || ''; bv = b.oppStatus || ''; break;
case 'cfda': av = (a.cfdaList?.[0] || ''); bv = (b.cfdaList?.[0] || ''); break;
}
if (av < bv) return dir === 'asc' ? -1 : 1;
if (av > bv) return dir === 'asc' ? 1 : -1;
return 0;
}
export default function RiverTable({ grants }: { grants: Grant[] }) {
const [sortKey, setSortKey] = useState<SortKey>('closeDate');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const sorted = useMemo(() => {
return [...grants].sort((a, b) => compare(a, b, sortKey, sortDir));
}, [grants, sortKey, sortDir]);
function clickSort(k: SortKey) {
if (k === sortKey) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(k);
setSortDir('asc');
}
}
function arrow(k: SortKey) {
if (k !== sortKey) return <span style={{ opacity: 0.25 }}> ↕</span>;
return <span style={{ color: '#10b981' }}> {sortDir === 'asc' ? '↑' : '↓'}</span>;
}
const TH: React.CSSProperties = {
padding: '10px 14px', textAlign: 'left', fontSize: 11,
fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase',
color: '#6c7d75', background: '#0a0f0d', borderBottom: '1px solid #1f2925',
cursor: 'pointer', userSelect: 'none' as const, whiteSpace: 'nowrap' as const,
};
const TD: React.CSSProperties = {
padding: '10px 14px', borderBottom: '1px solid #1f2925',
fontSize: 13, color: '#d8e8df', verticalAlign: 'top' as const,
};
return (
<div style={{ padding: '20px 28px 40px', background: '#07090a' }}>
<div style={{ marginBottom: 12, fontSize: 11, color: '#6c7d75',
textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600 }}>
Grid view · {sorted.length} opportunities · click any column to sort
</div>
<div style={{ overflowX: 'auto', border: '1px solid #1f2925', borderRadius: 8 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#0f1311' }}>
<thead>
<tr>
<th style={TH} onClick={() => clickSort('title')}>opportunity{arrow('title')}</th>
<th style={TH} onClick={() => clickSort('agency')}>agency{arrow('agency')}</th>
<th style={TH} onClick={() => clickSort('cfda')}>CFDA{arrow('cfda')}</th>
<th style={TH} onClick={() => clickSort('openDate')}>opens{arrow('openDate')}</th>
<th style={TH} onClick={() => clickSort('closeDate')}>closes{arrow('closeDate')}</th>
<th style={TH} onClick={() => clickSort('oppStatus')}>status{arrow('oppStatus')}</th>
</tr>
</thead>
<tbody>
{sorted.map((g) => (
<tr key={g.id} style={{ transition: 'background 0.1s' }}>
<td style={TD}>
<a
href={`https://www.grants.gov/search-results-detail/${g.id}`}
target="_blank" rel="noopener"
style={{ color: '#d8e8df', textDecoration: 'none', fontWeight: 500 }}
>
{g.title || 'Untitled'}
</a>
<div style={{ color: '#6c7d75', fontSize: 11, marginTop: 2 }}>
#{g.number || '—'}
</div>
</td>
<td style={TD}>{g.agency || g.agencyCode || '—'}</td>
<td style={{ ...TD, fontFamily: 'ui-monospace, monospace', fontSize: 12 }}>
{g.cfdaList?.join(', ') || '—'}
</td>
<td style={{ ...TD, fontFamily: 'ui-monospace, monospace', fontSize: 12, color: '#6c7d75' }}>
{g.openDate || '—'}
</td>
<td style={{ ...TD, fontFamily: 'ui-monospace, monospace', fontSize: 12 }}>
{g.closeDate || '—'}
</td>
<td style={TD}>
<span style={{
fontSize: 10, padding: '2px 8px', borderRadius: 99,
fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em',
background: g.oppStatus === 'posted' ? '#064e3b' : '#1e293b',
color: g.oppStatus === 'posted' ? '#10b981' : '#93c5fd',
}}>{g.oppStatus || '—'}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}