← 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>
  );
}