← back to Freddy

components/granters/GrantersTab.tsx

309 lines

'use client';

import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonList } from '../Skeleton';
import {
  Landmark, Plus, X, Loader2, ExternalLink,
  DollarSign, Tag, MapPin, Calendar,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';

interface Granter {
  id: string;
  name: string;
  description: string | null;
  website_url: string | null;
  city: string | null;
  state: string | null;
  funding_areas: string[];
  annual_budget: string | null;
  min_grant: string | null;
  max_grant: string | null;
  grants_per_year: number | null;
  preference_tags: string[];
  status: string;
  created_at: string;
}

const SORT_OPTIONS = [
  { value: 'name_asc', label: 'Name A-Z' },
  { value: 'name_desc', label: 'Name Z-A' },
  { value: 'budget_desc', label: 'Largest Budget' },
  { value: 'date_desc', label: 'Newest First' },
  { value: 'date_asc', label: 'Oldest First' },
];

const SORT_CONFIGS: Record<string, SortConfig> = {
  name_asc:   { key: 'name', direction: 'asc', type: 'string' },
  name_desc:  { key: 'name', direction: 'desc', type: 'string' },
  budget_desc:{ key: 'annual_budget', direction: 'desc', type: 'number' },
  date_desc:  { key: 'created_at', direction: 'desc', type: 'date' },
  date_asc:   { key: 'created_at', direction: 'asc', type: 'date' },
};

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

export default function GrantersTab() {
  const { addToast } = useToast();
  const [granters, setGranters] = useState<Granter[]>([]);
  const [loading, setLoading] = useState(true);
  const [showCreate, setShowCreate] = useState(false);
  const [sortBy, setSortBy] = useState('name_asc');
  const sortedGranters = useClientSort(granters, sortBy, SORT_CONFIGS);

  const fetchGranters = useCallback(async () => {
    try {
      const res = await fetch('/api/granters', { credentials: 'include' });
      if (res.ok) {
        const data = await res.json();
        setGranters(data.granters || []);
      }
    } catch (err) {
      console.error('Failed to fetch granters:', err);
    } finally {
      setLoading(false);
    }
  }, []);

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

  const handleCreate = async (formData: Record<string, string>) => {
    try {
      const res = await fetch('/api/granters', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({
          ...formData,
          funding_areas: formData.funding_areas ? formData.funding_areas.split(',').map(s => s.trim()).filter(Boolean) : [],
          preference_tags: formData.preference_tags ? formData.preference_tags.split(',').map(s => s.trim()).filter(Boolean) : [],
          annual_budget: formData.annual_budget ? parseFloat(formData.annual_budget) : null,
          min_grant: formData.min_grant ? parseFloat(formData.min_grant) : null,
          max_grant: formData.max_grant ? parseFloat(formData.max_grant) : null,
          grants_per_year: formData.grants_per_year ? parseInt(formData.grants_per_year) : null,
        }),
      });
      if (res.ok) {
        setShowCreate(false);
        await fetchGranters();
        addToast('Granter added', 'success');
      } else {
        addToast('Failed to add granter', 'error');
      }
    } catch (err) {
      console.error('Create granter error:', err);
      addToast('Failed to add granter', 'error');
    }
  };

  const fmtMoney = (val: string | null) => {
    if (!val) return 'N/A';
    const n = parseFloat(val);
    if (n >= 1000000) return `$${(n / 1000000).toFixed(1)}M`;
    if (n >= 1000) return `$${(n / 1000).toFixed(0)}K`;
    return `$${n.toLocaleString()}`;
  };

  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)' }}>
            <Landmark size={20} style={{ color: '#60a5fa' }} />
            Granters
          </h2>
          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
            Foundations, corporations, and government funders
          </p>
        </div>
        <div className="flex items-center gap-2">
          <SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
          <button onClick={() => setShowCreate(true)} className="btn btn-primary">
            <Plus size={14} />
            Add Granter
          </button>
        </div>
      </div>

      {loading ? (
        <SkeletonList count={4} />
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          <AnimatePresence>
            {sortedGranters.map((g, i) => (
              <motion.div key={g.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-4 flex flex-col gap-3">
                <div className="flex items-start justify-between">
                  <div className="flex-1 min-w-0">
                    <h3 className="text-sm font-semibold truncate" style={{ color: 'var(--color-text)' }}>{g.name}</h3>
                    {(g.city || g.state) && (
                      <div className="flex items-center gap-1 text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
                        <MapPin size={10} /> {[g.city, g.state].filter(Boolean).join(', ')}
                      </div>
                    )}
                  </div>
                  {g.website_url && (
                    <a href={g.website_url} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
                      <ExternalLink size={12} />
                    </a>
                  )}
                </div>

                {g.description && (
                  <p className="text-xs line-clamp-2" style={{ color: 'var(--color-text-muted)' }}>{g.description}</p>
                )}

                <div className="grid grid-cols-3 gap-2">
                  <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
                    <DollarSign size={12} style={{ color: '#22c55e', margin: '0 auto 2px' }} />
                    <div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{fmtMoney(g.annual_budget)}</div>
                    <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Budget</div>
                  </div>
                  <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
                    <DollarSign size={12} style={{ color: '#fbbf24', margin: '0 auto 2px' }} />
                    <div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>
                      {fmtMoney(g.min_grant)}-{fmtMoney(g.max_grant)}
                    </div>
                    <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Range</div>
                  </div>
                  <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
                    <Calendar size={12} style={{ color: '#c084fc', margin: '0 auto 2px' }} />
                    <div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{g.grants_per_year ?? 'N/A'}</div>
                    <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Per Year</div>
                  </div>
                </div>

                {g.funding_areas && g.funding_areas.length > 0 && (
                  <div className="flex items-center gap-1 flex-wrap">
                    {g.funding_areas.slice(0, 4).map((area: string) => (
                      <span key={area} className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', color: '#60a5fa', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
                        {area}
                      </span>
                    ))}
                  </div>
                )}

                {g.preference_tags && g.preference_tags.length > 0 && (
                  <div className="flex items-center gap-1 flex-wrap">
                    {g.preference_tags.slice(0, 3).map((tag: string) => (
                      <span key={tag} className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border)' }}>
                        <Tag size={10} style={{ display: 'inline', marginRight: 2 }} />{tag}
                      </span>
                    ))}
                  </div>
                )}
              </motion.div>
            ))}
          </AnimatePresence>
        </div>
      )}

      {!loading && granters.length === 0 && (
        <div className="text-center py-12">
          <Landmark size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
          <p style={{ color: 'var(--color-text-muted)' }}>No granters yet. Add funding organizations to start matching.</p>
        </div>
      )}

      {showCreate && <CreateGranterModal onClose={() => setShowCreate(false)} onSubmit={handleCreate} />}
    </div>
  );
}

function CreateGranterModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Record<string, string>) => void }) {
  useEffect(() => {
    const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', handleKey);
    return () => window.removeEventListener('keydown', handleKey);
  }, [onClose]);

  const [form, setForm] = useState({ name: '', description: '', website_url: '', city: '', state: '', funding_areas: '', annual_budget: '', min_grant: '', max_grant: '', grants_per_year: '', preference_tags: '' });
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = async () => {
    setSubmitting(true);
    await onSubmit(form);
    setSubmitting(false);
  };

  const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setForm(f => ({ ...f, [k]: e.target.value }));

  return (
    <div className="modal-overlay" onClick={onClose}>
      <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="modal-content" onClick={(e) => e.stopPropagation()}>
        <div className="flex items-center justify-between mb-4">
          <h3 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Add Granter</h3>
          <button onClick={onClose} className="btn btn-ghost btn-sm"><X size={16} /></button>
        </div>
        <div className="space-y-3">
          <div>
            <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Name *</label>
            <input className="input" value={form.name} onChange={set('name')} placeholder="Foundation name" />
          </div>
          <div>
            <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Description</label>
            <textarea className="input" rows={2} value={form.description} onChange={set('description')} placeholder="About this funder..." />
          </div>
          <div>
            <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Website</label>
            <input className="input" value={form.website_url} onChange={set('website_url')} placeholder="https://..." />
          </div>
          <div className="grid grid-cols-2 gap-3">
            <div>
              <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>City</label>
              <input className="input" value={form.city} onChange={set('city')} />
            </div>
            <div>
              <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>State</label>
              <input className="input" value={form.state} onChange={set('state')} />
            </div>
          </div>
          <div>
            <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Funding Areas (comma-separated)</label>
            <input className="input" value={form.funding_areas} onChange={set('funding_areas')} placeholder="education, health, environment" />
          </div>
          <div className="grid grid-cols-3 gap-3">
            <div>
              <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Annual Budget ($)</label>
              <input className="input" type="number" value={form.annual_budget} onChange={set('annual_budget')} />
            </div>
            <div>
              <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Min Grant ($)</label>
              <input className="input" type="number" value={form.min_grant} onChange={set('min_grant')} />
            </div>
            <div>
              <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Max Grant ($)</label>
              <input className="input" type="number" value={form.max_grant} onChange={set('max_grant')} />
            </div>
          </div>
          <div className="grid grid-cols-2 gap-3">
            <div>
              <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Grants Per Year</label>
              <input className="input" type="number" value={form.grants_per_year} onChange={set('grants_per_year')} />
            </div>
            <div>
              <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Preference Tags</label>
              <input className="input" value={form.preference_tags} onChange={set('preference_tags')} placeholder="innovation, youth" />
            </div>
          </div>
          <div className="flex justify-end gap-2 pt-2">
            <button onClick={onClose} className="btn btn-secondary">Cancel</button>
            <button onClick={handleSubmit} disabled={!form.name || submitting} className="btn btn-primary">
              {submitting ? <Loader2 size={14} className="animate-spin" /> : null}
              Add Granter
            </button>
          </div>
        </div>
      </motion.div>
    </div>
  );
}