← back to Grant

components/collaborations/CollaborationsTab.tsx

732 lines

'use client';

import { useState, useEffect, useCallback, useRef } from 'react';
import { SkeletonList } from '../Skeleton';
import {
  Users,
  Building2,
  Landmark,
  MapPin,
  Search,
  Sparkles,
  Loader2,
  ChevronDown,
  ChevronUp,
  ExternalLink,
  Mail,
  Phone,
  Globe,
  MessageSquare,
  X,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useAuth } from '../AuthProvider';
import { useDebounce } from '@/hooks/useDebounce';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';

const COLLAB_SORT_OPTIONS = [
  { value: 'name_asc', label: 'Name A-Z' },
  { value: 'type', label: 'Type A-Z' },
  { value: 'status', label: 'Status A-Z' },
  { value: 'date_desc', label: 'Newest First' },
];

const COLLAB_SORT_CONFIGS: Record<string, SortConfig> = {
  name_asc: { key: 'name', direction: 'asc', type: 'string' },
  type: { key: 'collab_type', direction: 'asc', type: 'string' },
  status: { key: 'status', direction: 'asc', type: 'string' },
  date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
};

/* ─── Types ──────────────────────────────────────────────────────────────── */
interface Collaboration {
  id: string;
  collab_type: string;
  name: string;
  title: string | null;
  organization: string | null;
  website_url: string | null;
  email: string | null;
  phone: string | null;
  district: string | null;
  state: string | null;
  ai_reason: string | null;
  ai_relevance: number | null;
  ai_talking_points: string[];
  status: string;
  notes: string | null;
  last_contacted: string | null;
  created_at: string;
}

type TypeFilter = 'all' | 'nonprofit' | 'politician' | 'corporation' | 'municipality';
type StatusFilter = 'all' | 'suggested' | 'contacted' | 'in_discussion' | 'partnered';

const TYPE_OPTIONS: { id: TypeFilter; label: string; Icon: React.ElementType }[] = [
  { id: 'all', label: 'All', Icon: Users },
  { id: 'nonprofit', label: 'Nonprofits', Icon: Users },
  { id: 'politician', label: 'Politicians', Icon: Landmark },
  { id: 'corporation', label: 'Corporations', Icon: Building2 },
  { id: 'municipality', label: 'Municipalities', Icon: MapPin },
];

const STATUS_OPTIONS: { id: StatusFilter; label: string }[] = [
  { id: 'all', label: 'All Status' },
  { id: 'suggested', label: 'Suggested' },
  { id: 'contacted', label: 'Contacted' },
  { id: 'in_discussion', label: 'In Discussion' },
  { id: 'partnered', label: 'Partnered' },
];

const TYPE_COLORS: Record<string, { bg: string; text: string; border: string }> = {
  nonprofit: { bg: 'rgba(5,150,105,0.15)', text: '#34d399', border: 'rgba(5,150,105,0.3)' },
  politician: { bg: 'rgba(59,130,246,0.15)', text: '#60a5fa', border: 'rgba(59,130,246,0.3)' },
  corporation: { bg: 'rgba(245,158,11,0.15)', text: '#fbbf24', border: 'rgba(245,158,11,0.3)' },
  municipality: { bg: 'rgba(168,85,247,0.15)', text: '#c084fc', border: 'rgba(168,85,247,0.3)' },
};

const STATUS_COLORS: Record<string, { bg: string; text: string; border: string }> = {
  suggested: { bg: 'rgba(59,130,246,0.15)', text: '#60a5fa', border: 'rgba(59,130,246,0.3)' },
  contacted: { bg: 'rgba(245,158,11,0.15)', text: '#fbbf24', border: 'rgba(245,158,11,0.3)' },
  in_discussion: { bg: 'rgba(168,85,247,0.15)', text: '#c084fc', border: 'rgba(168,85,247,0.3)' },
  partnered: { 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)' },
  archived: { bg: 'rgba(107,127,117,0.15)', text: '#6b7f75', border: 'rgba(107,127,117,0.3)' },
};

/* ─── Main Component ─────────────────────────────────────────────────────── */
export default function CollaborationsTab() {
  const { addToast } = useToast();
  const { showAdmin } = useAuth();
  const [collabs, setCollabs] = useState<Collaboration[]>([]);
  const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
  const [loading, setLoading] = useState(true);
  const [discovering, setDiscovering] = useState(false);
  const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
  const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
  const [search, setSearch] = useState('');
  const debouncedSearch = useDebounce(search, 300);
  const [expandedId, setExpandedId] = useState<string | null>(null);
  const [outreachModal, setOutreachModal] = useState<{ name: string; org: string; type: string } | null>(null);
  const [sortBy, setSortBy] = useState('name_asc');
  const sortedCollabs = useClientSort(collabs, sortBy, COLLAB_SORT_CONFIGS);

  const fetchCollabs = useCallback(async () => {
    try {
      const params = new URLSearchParams();
      if (typeFilter !== 'all') params.set('collab_type', typeFilter);
      if (statusFilter !== 'all') params.set('status', statusFilter);
      if (debouncedSearch) params.set('search', debouncedSearch);
      const res = await fetch(`/api/collaborations?${params}`, { credentials: 'include' });
      if (res.ok) {
        const data = await res.json();
        setCollabs(data.rows || []);
        setTypeCounts(data.typeCounts || {});
      }
    } catch {
      // fetch error handled by loading state
    } finally {
      setLoading(false);
    }
  }, [typeFilter, statusFilter, debouncedSearch]);

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

  const handleDiscover = async () => {
    setDiscovering(true);
    try {
      const res = await fetch('/api/collaborations/discover', {
        method: 'POST',
        credentials: 'include',
      });
      if (res.ok) {
        await fetchCollabs();
        addToast('Collaborators discovered', 'success');
      } else {
        addToast('Discovery failed', 'error');
      }
    } catch {
      addToast('Discovery failed', 'error');
    } finally {
      setDiscovering(false);
    }
  };

  const handleStatusChange = async (id: string, newStatus: string) => {
    try {
      await fetch('/api/collaborations', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({ id, status: newStatus }),
      });
      await fetchCollabs();
      addToast(`Status updated to ${newStatus.replace(/_/g, ' ')}`, 'success');
    } catch {
      addToast('Status update failed', 'error');
    }
  };

  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)' }}>
            Collaborations
          </h2>
          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
            Build partnerships across sectors to amplify your impact.
          </p>
        </div>
        {showAdmin && (
          <button
            className="btn btn-secondary"
            onClick={handleDiscover}
            disabled={discovering}
          >
            {discovering ? (
              <Loader2 size={15} className="animate-spin" />
            ) : (
              <Sparkles size={15} />
            )}
            {discovering ? 'Discovering...' : 'AI Discover Collaborators'}
          </button>
        )}
      </div>

      {/* Type Filter Pills */}
      <div className="flex gap-2 flex-wrap">
        {TYPE_OPTIONS.map(({ id, label, Icon }) => {
          const count = typeCounts[id] ?? 0;
          const isActive = typeFilter === id;
          return (
            <button
              key={id}
              className="btn btn-sm"
              onClick={() => setTypeFilter(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)',
              }}
            >
              <Icon size={13} />
              {label}
              {count > 0 && <span className="ml-1 text-xs" style={{ opacity: 0.7 }}>({count})</span>}
            </button>
          );
        })}
      </div>

      {/* Sort + Status Filter + Search */}
      <div className="flex items-center gap-3 flex-wrap">
        <SortDropdown options={COLLAB_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
        <div className="relative flex-1 min-w-[200px]">
          <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 collaborators..."
            aria-label="Search"
            style={{ paddingLeft: '2.25rem' }}
            value={search}
            onChange={(e) => setSearch(e.target.value)}
          />
        </div>
        <select
          className="input"
          style={{ width: 'auto' }}
          value={statusFilter}
          onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
          aria-label="Filter by status"
        >
          {STATUS_OPTIONS.map(({ id, label }) => (
            <option key={id} value={id}>{label}</option>
          ))}
        </select>
      </div>

      {/* Cards */}
      {loading ? (
        <SkeletonList count={4} />
      ) : sortedCollabs.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)',
              }}
            >
              <Users size={24} style={{ color: 'var(--color-text-muted)' }} />
            </div>
            <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
              No collaborators found
            </h3>
            <p className="text-sm max-w-md" style={{ color: 'var(--color-text-muted)' }}>
              Click &quot;AI Discover Collaborators&quot; to find potential partners for your mission.
            </p>
          </div>
        </div>
      ) : (
        <div className="space-y-3">
          {sortedCollabs.map((c) => {
            const isExpanded = expandedId === c.id;
            const typeStyle = TYPE_COLORS[c.collab_type] || TYPE_COLORS.nonprofit;
            const statusStyle = STATUS_COLORS[c.status] || STATUS_COLORS.suggested;
            const relevancePct = c.ai_relevance != null ? Math.round(c.ai_relevance * 100) : null;

            return (
              <div key={c.id} className="card" style={{ padding: 0 }}>
                <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" style={{ color: 'var(--color-text)' }}>
                          {c.name}
                        </h3>
                        <span
                          className="badge"
                          style={{
                            backgroundColor: typeStyle.bg,
                            color: typeStyle.text,
                            border: `1px solid ${typeStyle.border}`,
                          }}
                        >
                          {c.collab_type}
                        </span>
                        <span
                          className="badge"
                          style={{
                            backgroundColor: statusStyle.bg,
                            color: statusStyle.text,
                            border: `1px solid ${statusStyle.border}`,
                          }}
                        >
                          {c.status.replace(/_/g, ' ')}
                        </span>
                      </div>

                      <div className="flex items-center gap-3 text-xs flex-wrap" style={{ color: 'var(--color-text-muted)' }}>
                        {c.title && <span>{c.title}</span>}
                        {c.organization && (
                          <span className="font-medium" style={{ color: 'var(--color-text-secondary)' }}>
                            {c.organization}
                          </span>
                        )}
                        {c.district && <span>{c.district}</span>}
                        {c.state && <span>{c.state}</span>}
                      </div>

                      {c.ai_reason && (
                        <p className="text-xs mt-2 leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
                          {c.ai_reason}
                        </p>
                      )}
                    </div>

                    <div className="flex items-center gap-2 shrink-0">
                      {/* Relevance score */}
                      {relevancePct != null && (
                        <div className="text-center">
                          <div
                            className="w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold"
                            style={{
                              backgroundColor: relevancePct >= 70 ? 'rgba(34,197,94,0.15)' : 'rgba(245,158,11,0.15)',
                              color: relevancePct >= 70 ? '#22c55e' : '#f59e0b',
                              border: `1px solid ${relevancePct >= 70 ? 'rgba(34,197,94,0.3)' : 'rgba(245,158,11,0.3)'}`,
                            }}
                          >
                            {relevancePct}%
                          </div>
                        </div>
                      )}
                      <button
                        className="btn btn-ghost btn-sm"
                        onClick={() => setExpandedId(isExpanded ? null : c.id)}
                        aria-label={isExpanded ? `Collapse ${c.name}` : `Expand ${c.name}`}
                        aria-expanded={isExpanded}
                      >
                        {isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
                      </button>
                    </div>
                  </div>
                </div>

                {/* Expanded */}
                {isExpanded && (
                  <div
                    className="px-4 pb-4 space-y-3"
                    style={{ borderTop: '1px solid var(--color-border)' }}
                  >
                    {/* Contact info */}
                    <div className="flex gap-3 flex-wrap pt-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
                      {c.email && (
                        <a href={`mailto:${c.email}`} className="flex items-center gap-1 hover:underline" style={{ color: '#60a5fa' }}>
                          <Mail size={12} />
                          {c.email}
                        </a>
                      )}
                      {c.phone && (
                        <span className="flex items-center gap-1">
                          <Phone size={12} />
                          {c.phone}
                        </span>
                      )}
                      {c.website_url && (
                        <a href={c.website_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:underline" style={{ color: '#60a5fa' }}>
                          <Globe size={12} />
                          Website
                        </a>
                      )}
                    </div>

                    {/* Talking Points */}
                    {c.ai_talking_points && c.ai_talking_points.length > 0 && (
                      <div>
                        <h4 className="text-xs font-semibold mb-1 flex items-center gap-1" style={{ color: '#34d399' }}>
                          <Sparkles size={12} />
                          Talking Points
                        </h4>
                        <ul className="space-y-1">
                          {c.ai_talking_points.map((point, i) => (
                            <li
                              key={i}
                              className="text-xs leading-relaxed pl-3 relative"
                              style={{ color: 'var(--color-text-secondary)' }}
                            >
                              <span
                                className="absolute left-0 top-1.5 w-1.5 h-1.5 rounded-full"
                                style={{ backgroundColor: '#34d399' }}
                              />
                              {point}
                            </li>
                          ))}
                        </ul>
                      </div>
                    )}

                    {/* Relevance bar */}
                    {relevancePct != null && (
                      <div>
                        <div className="flex items-center justify-between mb-1">
                          <span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
                            AI Relevance
                          </span>
                          <span className="text-xs font-bold" style={{ color: relevancePct >= 70 ? '#34d399' : '#fbbf24' }}>
                            {relevancePct}%
                          </span>
                        </div>
                        <div className="h-1.5 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--color-surface-el)' }}>
                          <div
                            className="h-full rounded-full"
                            style={{
                              width: `${relevancePct}%`,
                              backgroundColor: relevancePct >= 70 ? '#34d399' : '#fbbf24',
                            }}
                          />
                        </div>
                      </div>
                    )}

                    {/* Actions */}
                    <div className="flex gap-2 flex-wrap pt-1">
                      <button
                        className="btn btn-primary btn-sm"
                        onClick={() =>
                          setOutreachModal({
                            name: c.name,
                            org: c.organization || '',
                            type: c.collab_type,
                          })
                        }
                      >
                        <MessageSquare size={14} />
                        Draft Outreach
                      </button>
                      <select
                        className="input"
                        style={{ width: 'auto', padding: '0.25rem 0.5rem', fontSize: '0.8125rem' }}
                        value={c.status}
                        onChange={(e) => handleStatusChange(c.id, e.target.value)}
                        aria-label={`Status for ${c.name}`}
                      >
                        <option value="suggested">Suggested</option>
                        <option value="contacted">Contacted</option>
                        <option value="in_discussion">In Discussion</option>
                        <option value="partnered">Partnered</option>
                        <option value="declined">Declined</option>
                        <option value="archived">Archived</option>
                      </select>
                    </div>
                  </div>
                )}
              </div>
            );
          })}
        </div>
      )}

      {/* Outreach Modal */}
      {outreachModal && (
        <OutreachGenerateModal
          targetName={outreachModal.name}
          targetOrg={outreachModal.org}
          targetType={outreachModal.type === 'politician' ? 'official' : outreachModal.type}
          onClose={() => setOutreachModal(null)}
        />
      )}
    </div>
  );
}

/* ─── Outreach Generate Modal ────────────────────────────────────────────── */
function OutreachGenerateModal({
  targetName,
  targetOrg,
  targetType,
  onClose,
}: {
  targetName: string;
  targetOrg: string;
  targetType: string;
  onClose: () => void;
}) {
  const { addToast } = useToast();
  const { canUseAI } = useAuth();
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  // Store previously focused element and focus the modal on mount
  useEffect(() => {
    previousFocusRef.current = document.activeElement as HTMLElement;
    modalRef.current?.focus();
    return () => {
      previousFocusRef.current?.focus();
    };
  }, []);

  // Escape to close + focus trapping
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
    if (e.key === 'Escape') {
      onClose();
      return;
    }
    if (e.key === 'Tab' && modalRef.current) {
      const focusable = modalRef.current.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      if (focusable.length === 0) return;
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      if (e.shiftKey) {
        if (document.activeElement === first) {
          e.preventDefault();
          last.focus();
        }
      } else {
        if (document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      }
    }
  }, [onClose]);

  const [templateType, setTemplateType] = useState('introduction');
  const [context, setContext] = useState('');
  const [generating, setGenerating] = useState(false);
  const [result, setResult] = useState<{ subject: string; body_html: string; body_text: string } | null>(null);
  const [copied, setCopied] = useState(false);

  const handleGenerate = async () => {
    setGenerating(true);
    try {
      const res = await fetch('/api/outreach/generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({
          target_name: targetName,
          target_org: targetOrg,
          target_type: targetType,
          template_type: templateType,
          context,
        }),
      });
      if (res.ok) {
        const data = await res.json();
        setResult(data);
        addToast('Outreach email generated', 'success');
      } else {
        addToast('Generation failed', 'error');
      }
    } catch {
      addToast('Generation failed', 'error');
    } finally {
      setGenerating(false);
    }
  };

  const handleCopy = async () => {
    if (!result) return;
    try {
      await navigator.clipboard.writeText(result.body_text);
    } catch {
      const ta = document.createElement('textarea');
      ta.value = result.body_text;
      document.body.appendChild(ta);
      ta.select();
      document.execCommand('copy');
      document.body.removeChild(ta);
    }
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
    addToast('Copied to clipboard', 'success');
  };

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center p-4"
      style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
      onClick={onClose}
      role="dialog"
      aria-modal="true"
      aria-labelledby="outreach-modal-title"
      onKeyDown={handleKeyDown}
    >
      <div
        ref={modalRef}
        className="card w-full max-w-2xl max-h-[90vh] overflow-auto"
        style={{ backgroundColor: 'var(--color-surface)' }}
        onClick={(e) => e.stopPropagation()}
        tabIndex={-1}
      >
        <div className="flex items-center justify-between mb-4">
          <div>
            <h3 id="outreach-modal-title" className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
              Draft Outreach
            </h3>
            <p className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
              To: {targetName} {targetOrg ? `(${targetOrg})` : ''}
            </p>
          </div>
          <button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close dialog">
            <X size={16} />
          </button>
        </div>

        {!result ? (
          <div className="space-y-3">
            <div>
              <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
                Email Type
              </label>
              <select
                className="input"
                value={templateType}
                onChange={(e) => setTemplateType(e.target.value)}
              >
                <option value="introduction">Introduction</option>
                <option value="meeting_request">Meeting Request</option>
                <option value="follow_up">Follow Up</option>
                <option value="thank_you">Thank You</option>
                <option value="donation_ask">Donation Ask</option>
              </select>
            </div>
            <div>
              <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
                Additional Context (optional)
              </label>
              <textarea
                className="input"
                rows={3}
                placeholder="Any specific talking points or context..."
                value={context}
                onChange={(e) => setContext(e.target.value)}
                style={{ resize: 'vertical' }}
              />
            </div>
            {canUseAI ? (
              <button
                className="btn btn-primary w-full"
                onClick={handleGenerate}
                disabled={generating}
              >
                {generating ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
                {generating ? 'Generating...' : 'Generate Email'}
              </button>
            ) : (
              <p className="text-xs text-center" style={{ color: 'var(--color-text-muted)' }}>
                AI email drafting isn’t available for this account.
              </p>
            )}
          </div>
        ) : (
          <div className="space-y-3">
            <div
              className="p-3 rounded-lg"
              style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}
            >
              <h4 className="text-xs font-semibold mb-1" style={{ color: 'var(--color-text-secondary)' }}>
                Subject
              </h4>
              <p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
                {result.subject}
              </p>
            </div>
            <div
              className="p-4 rounded-lg"
              style={{
                backgroundColor: 'var(--color-surface-el)',
                border: '1px solid var(--color-border)',
                color: 'var(--color-text-secondary)',
                fontSize: '0.8125rem',
                lineHeight: '1.6',
              }}
            >
              {result.body_text.split('\n').map((line, i) => (
                <p key={i} style={{ marginBottom: line.trim() ? '0.5rem' : '0.25rem' }}>
                  {line || '\u00A0'}
                </p>
              ))}
            </div>
            <div className="flex gap-2">
              <button className="btn btn-secondary flex-1" onClick={handleCopy}>
                <Mail size={14} />
                {copied ? 'Copied!' : 'Copy to Clipboard'}
              </button>
              <button
                className="btn btn-primary flex-1"
                onClick={() => {
                  window.open(
                    `mailto:?subject=${encodeURIComponent(result.subject)}&body=${encodeURIComponent(result.body_text)}`,
                    '_blank',
                  );
                }}
              >
                <ExternalLink size={14} />
                Open in Email
              </button>
            </div>
            <button className="btn btn-ghost btn-sm w-full" onClick={() => setResult(null)}>
              Generate Another
            </button>
          </div>
        )}
      </div>
    </div>
  );
}