← back to Freddy

components/matches/MatchesTab.tsx

263 lines

'use client';

import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonList } from '../Skeleton';
import {
  Sparkles, Loader2, RefreshCw, ArrowRight,
  Flame, Building2, Landmark, Target,
  CheckCircle, Clock, ThumbsUp, Ban,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';

interface Match {
  id: string;
  cause_id: string;
  org_id: string;
  granter_id: string | null;
  match_score: number;
  cause_alignment: number;
  public_support: number;
  granter_fit: number;
  ai_reasoning: string | null;
  status: string;
  funding_amount: string | null;
  cause_title: string;
  urgency_score: number;
  cause_category: string;
  org_name: string;
  trust_score: number;
  impact_score: number;
  granter_name: string | null;
  granter_budget: string | null;
  created_at: string;
}

const SORT_OPTIONS = [
  { value: 'score_desc', label: 'Best Match' },
  { value: 'date_desc', label: 'Newest First' },
  { value: 'date_asc', label: 'Oldest First' },
  { value: 'status', label: 'Status A-Z' },
];

const SORT_CONFIGS: Record<string, SortConfig> = {
  score_desc: { key: 'match_score', direction: 'desc', type: 'number' },
  date_desc:  { key: 'created_at', direction: 'desc', type: 'date' },
  date_asc:   { key: 'created_at', direction: 'asc', type: 'date' },
  status:     { key: 'status', direction: 'asc', type: 'string' },
};

const STATUS_FILTERS = ['all', 'suggested', 'presented', 'accepted', 'funded', 'declined'];

const fadeIn = {
  hidden: { opacity: 0, y: 20 },
  visible: (i: number) => ({
    opacity: 1, y: 0,
    transition: { delay: i * 0.05, duration: 0.4 },
  }),
};

export default function MatchesTab() {
  const { addToast } = useToast();
  const [matches, setMatches] = useState<Match[]>([]);
  const [loading, setLoading] = useState(true);
  const [generating, setGenerating] = useState(false);
  const [statusFilter, setStatusFilter] = useState('all');
  const [sortBy, setSortBy] = useState('score_desc');
  const sortedMatches = useClientSort(matches, sortBy, SORT_CONFIGS);

  const fetchMatches = useCallback(async () => {
    try {
      const params = new URLSearchParams();
      if (statusFilter !== 'all') params.set('status', statusFilter);
      const res = await fetch(`/api/matches?${params}`, { credentials: 'include' });
      if (res.ok) {
        const data = await res.json();
        setMatches(data.matches || []);
      }
    } catch (err) {
      console.error('Failed to fetch matches:', err);
    } finally {
      setLoading(false);
    }
  }, [statusFilter]);

  useEffect(() => { fetchMatches(); }, [fetchMatches]);

  const handleGenerate = async () => {
    setGenerating(true);
    try {
      const res = await fetch('/api/matches/generate', {
        method: 'POST',
        credentials: 'include',
      });
      if (res.ok) {
        await fetchMatches();
        addToast('Matches generated', 'success');
      } else {
        addToast('Match generation failed', 'error');
      }
    } catch (err) {
      console.error('Generate error:', err);
      addToast('Match generation failed', 'error');
    } finally {
      setGenerating(false);
    }
  };

  const statusIcon = (s: string) => {
    switch (s) {
      case 'funded': return <CheckCircle size={14} style={{ color: '#22c55e' }} />;
      case 'accepted': return <ThumbsUp size={14} style={{ color: '#60a5fa' }} />;
      case 'declined': return <Ban size={14} style={{ color: '#ef4444' }} />;
      default: return <Clock size={14} style={{ color: '#fbbf24' }} />;
    }
  };

  const statusBadge = (s: string) => {
    switch (s) {
      case 'funded': return 'badge-success';
      case 'accepted': return 'badge-blue';
      case 'declined': return 'badge-error';
      case 'presented': return 'badge-warning';
      default: return 'badge-amber';
    }
  };

  return (
    <div className="p-6 space-y-6">
      <div className="flex items-center justify-between flex-wrap gap-3">
        <div>
          <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
            <Sparkles size={20} style={{ color: '#c084fc' }} />
            AI Matches
          </h2>
          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
            AI-generated cause-to-organization-to-granter match pipeline
          </p>
        </div>
        <div className="flex items-center gap-2">
          <button onClick={handleGenerate} disabled={generating} className="btn btn-primary">
            {generating ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
            {generating ? 'Generating...' : 'Generate Matches'}
          </button>
          <button onClick={fetchMatches} className="btn btn-secondary btn-sm">
            <RefreshCw size={14} />
          </button>
        </div>
      </div>

      {/* Status filter */}
      <div className="flex items-center gap-2 flex-wrap">
        <SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
        {STATUS_FILTERS.map((s) => (
          <button
            key={s}
            onClick={() => setStatusFilter(s)}
            className="btn btn-sm"
            style={{
              backgroundColor: statusFilter === s ? 'rgba(168, 85, 247, 0.2)' : 'var(--color-surface-el)',
              color: statusFilter === s ? '#c084fc' : 'var(--color-text-secondary)',
              borderColor: statusFilter === s ? 'rgba(168, 85, 247, 0.3)' : 'var(--color-border)',
            }}
          >
            {s === 'all' ? 'All' : s}
          </button>
        ))}
      </div>

      {loading ? (
        <SkeletonList count={4} />
      ) : (
        <div className="space-y-4">
          <AnimatePresence>
            {sortedMatches.map((m, i) => (
              <motion.div key={m.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-5">
                {/* Pipeline visual */}
                <div className="flex items-center gap-3 flex-wrap mb-4">
                  <div className="flex items-center gap-2 px-3 py-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
                    <Flame size={14} style={{ color: '#ef4444' }} />
                    <div>
                      <div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}>{m.cause_title}</div>
                      <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{m.cause_category}</div>
                    </div>
                  </div>
                  <ArrowRight size={16} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
                  <div className="flex items-center gap-2 px-3 py-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
                    <Building2 size={14} style={{ color: '#22c55e' }} />
                    <div>
                      <div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}>{m.org_name}</div>
                      <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Trust: {((m.trust_score || 0) * 100).toFixed(0)}%</div>
                    </div>
                  </div>
                  {m.granter_name && (
                    <>
                      <ArrowRight size={16} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
                      <div className="flex items-center gap-2 px-3 py-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
                        <Landmark size={14} style={{ color: '#60a5fa' }} />
                        <div>
                          <div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}>{m.granter_name}</div>
                          <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Funder</div>
                        </div>
                      </div>
                    </>
                  )}
                  <div className="ml-auto flex items-center gap-2">
                    {statusIcon(m.status)}
                    <span className={`badge ${statusBadge(m.status)}`}>{m.status}</span>
                  </div>
                </div>

                {/* Score breakdown */}
                <div className="grid grid-cols-4 gap-3 mb-3">
                  <ScoreBar label="Overall" value={m.match_score} color="#fbbf24" />
                  <ScoreBar label="Cause Alignment" value={m.cause_alignment} color="#ef4444" />
                  <ScoreBar label="Public Support" value={m.public_support} color="#22c55e" />
                  <ScoreBar label="Granter Fit" value={m.granter_fit} color="#60a5fa" />
                </div>

                {/* AI Reasoning */}
                {m.ai_reasoning && (
                  <div className="p-3 rounded-lg text-xs" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-secondary)', borderLeft: '3px solid #c084fc' }}>
                    <div className="flex items-center gap-1 mb-1">
                      <Sparkles size={10} style={{ color: '#c084fc' }} />
                      <span className="font-semibold" style={{ color: '#c084fc' }}>AI Reasoning</span>
                    </div>
                    {m.ai_reasoning}
                  </div>
                )}
              </motion.div>
            ))}
          </AnimatePresence>
        </div>
      )}

      {!loading && matches.length === 0 && (
        <div className="text-center py-12">
          <Sparkles size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
          <p style={{ color: 'var(--color-text-muted)' }}>
            No matches yet. Add causes and organizations, then click "Generate Matches".
          </p>
        </div>
      )}
    </div>
  );
}

function ScoreBar({ label, value, color }: { label: string; value: number; color: string }) {
  const pct = Math.round((value || 0) * 100);
  return (
    <div>
      <div className="flex items-center justify-between text-xs mb-1">
        <span style={{ color: 'var(--color-text-muted)' }}>{label}</span>
        <span style={{ color, fontWeight: 700 }}>{pct}%</span>
      </div>
      <div className="score-bar">
        <div className="score-bar-fill" style={{ width: `${pct}%`, background: color }} />
      </div>
    </div>
  );
}