← 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 &quot;AI Discover Grants&quot; 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>
  );
}