← back to Norma
components/contacts/ContactsTab.tsx
1230 lines
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search, Upload, Sparkles, ChevronRight, Briefcase, GraduationCap,
Users, Globe, MapPin, Tag, ExternalLink, RefreshCw, X, Building2,
Clock, UserCircle, FileText, Loader2, CheckCircle2, AlertCircle,
ChevronDown, ChevronUp, Link2, ArrowRight,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface Contact {
id: string;
first_name: string;
last_name: string;
email: string | null;
company: string | null;
position: string | null;
headline: string | null;
location: string | null;
linkedin_url: string | null;
website: string | null;
summary: string | null;
skills: string[];
groups: string[];
tags: string[];
enrichment_status: string;
enrichment_source: string | null;
enriched_at: string | null;
matched_org_id: string | null;
matched_org_name: string | null;
relevance_score: number | null;
connected_on: string | null;
created_at: string;
}
interface WorkHistory {
id: string;
company_name: string;
title: string | null;
start_date: string | null;
end_date: string | null;
is_current: boolean;
description: string | null;
location: string | null;
matched_org_name: string | null;
}
interface Education {
id: string;
school: string;
degree: string | null;
field_of_study: string | null;
start_year: number | null;
end_year: number | null;
}
interface ContactGroup {
id: string;
group_name: string;
group_type: string;
}
interface Coworker {
id: string;
first_name: string;
last_name: string;
company: string | null;
headline: string | null;
}
interface ContactDetail {
contact: Contact;
work_history: WorkHistory[];
education: Education[];
groups: ContactGroup[];
coworkers: Coworker[];
links: Array<{ linked_type: string; linked_id: string; link_type: string; weight: number }>;
}
interface EnrichStats {
total: string;
pending: string;
enriched: string;
failed: string;
org_matched: string;
total_work_records: string;
total_edu_records: string;
total_groups: string;
}
interface RelatedContact {
id: string;
first_name: string;
last_name: string;
headline: string | null;
company: string | null;
location: string | null;
linkedin_url: string | null;
// for coworkers
role_at_org?: string | null;
start_date?: string | null;
end_date?: string | null;
is_current?: boolean;
// for alumni
degree?: string | null;
field_of_study?: string | null;
start_year?: number | null;
end_year?: number | null;
// for skills
skills?: string[];
}
/* ─── Helpers ────────────────────────────────────────────────────────────── */
function formatDate(d: string | null): string {
if (!d) return '';
try {
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
} catch { return ''; }
}
function yearFromDate(d: string | null): string {
if (!d) return '';
try { return new Date(d).getFullYear().toString(); } catch { return ''; }
}
const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
pending: { bg: 'rgba(234, 179, 8, 0.15)', text: '#eab308' },
enriched: { bg: 'rgba(34, 197, 94, 0.15)', text: '#22c55e' },
failed: { bg: 'rgba(239, 68, 68, 0.15)', text: '#ef4444' },
};
function avatarGradient(seed: string) {
const gradients = [
'linear-gradient(135deg, #f472b6, #ec4899)',
'linear-gradient(135deg, #a78bfa, #7c3aed)',
'linear-gradient(135deg, #34d399, #059669)',
'linear-gradient(135deg, #60a5fa, #2563eb)',
'linear-gradient(135deg, #fb923c, #ea580c)',
'linear-gradient(135deg, #f87171, #dc2626)',
];
const idx = (seed.charCodeAt(0) + seed.charCodeAt(1)) % gradients.length;
return gradients[idx];
}
/* ─── Slide Panel (co-workers / related) ─────────────────────────────────── */
interface SlidePanelProps {
title: string;
subtitle?: string;
items: RelatedContact[];
total: number;
loading: boolean;
onClose: () => void;
onSelectContact: (id: string) => void;
mode: 'coworkers' | 'skill' | 'school' | 'group';
}
function SlidePanel({ title, subtitle, items, total, loading, onClose, onSelectContact, mode }: SlidePanelProps) {
const modeColors: Record<string, { accent: string; bg: string }> = {
coworkers: { accent: '#10b981', bg: 'rgba(16, 185, 129, 0.12)' },
skill: { accent: '#6366f1', bg: 'rgba(99, 102, 241, 0.12)' },
school: { accent: '#a855f7', bg: 'rgba(168, 85, 247, 0.12)' },
group: { accent: '#f59e0b', bg: 'rgba(245, 158, 11, 0.12)' },
};
const { accent, bg } = modeColors[mode] || modeColors.coworkers;
return (
<motion.div
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '100%', opacity: 0 }}
transition={{ type: 'spring', stiffness: 340, damping: 30 }}
style={{
position: 'fixed',
right: 0, top: 0, bottom: 0,
width: 360,
backgroundColor: 'var(--color-surface)',
borderLeft: `1px solid var(--color-border)`,
zIndex: 200,
display: 'flex',
flexDirection: 'column',
boxShadow: '-8px 0 32px rgba(0,0,0,0.4)',
}}
>
{/* Header */}
<div style={{
padding: '16px 20px',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'flex-start',
gap: 12,
}}>
<div style={{
width: 36, height: 36, borderRadius: 8,
backgroundColor: bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{mode === 'coworkers' && <Building2 size={16} style={{ color: accent }} />}
{mode === 'skill' && <Tag size={16} style={{ color: accent }} />}
{mode === 'school' && <GraduationCap size={16} style={{ color: accent }} />}
{mode === 'group' && <Users size={16} style={{ color: accent }} />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.2 }}>{title}</div>
{subtitle && <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{subtitle}</div>}
{!loading && (
<div style={{ fontSize: 10, color: accent, fontWeight: 600, marginTop: 3 }}>
{total} {total === 1 ? 'match' : 'matches'}
</div>
)}
</div>
<button
onClick={onClose}
style={{
width: 28, height: 28, borderRadius: 6, border: 'none',
backgroundColor: 'var(--color-surface-el)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--color-text-muted)', flexShrink: 0,
}}
>
<X size={14} />
</button>
</div>
{/* Content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
{loading ? (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--color-text-muted)' }}>
<Loader2 size={20} className="spinner" style={{ margin: '0 auto 8px' }} />
<div style={{ fontSize: 12 }}>Searching...</div>
</div>
) : items.length === 0 ? (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--color-text-muted)' }}>
<Users size={28} style={{ margin: '0 auto 8px', opacity: 0.4 }} />
<div style={{ fontSize: 13, fontWeight: 500 }}>No matches found</div>
<div style={{ fontSize: 11, marginTop: 4 }}>No other contacts match this criteria</div>
</div>
) : (
items.map(person => (
<div
key={person.id}
onClick={() => { onSelectContact(person.id); onClose(); }}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 20px', cursor: 'pointer', transition: 'background-color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div style={{
width: 32, height: 32, borderRadius: '50%', flexShrink: 0,
background: avatarGradient(person.first_name + person.last_name),
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 11, fontWeight: 700,
}}>
{person.first_name[0]}{person.last_name[0]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)' }}>
{person.first_name} {person.last_name}
</div>
{person.headline && (
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{person.headline}
</div>
)}
{/* Coworker overlap dates */}
{mode === 'coworkers' && (person.start_date || person.end_date || person.is_current) && (
<div style={{ fontSize: 10, color: accent, marginTop: 1, display: 'flex', alignItems: 'center', gap: 3 }}>
<Clock size={9} />
{person.role_at_org && <span>{person.role_at_org} · </span>}
{yearFromDate(person.start_date || null)}
{person.start_date ? ' – ' : ''}
{person.is_current ? 'Present' : yearFromDate(person.end_date || null)}
</div>
)}
{/* Alumni graduation year */}
{mode === 'school' && person.end_year && (
<div style={{ fontSize: 10, color: accent, marginTop: 1 }}>
{[person.degree, person.field_of_study].filter(Boolean).join(' in ')}
{person.end_year ? ` · Class of ${person.end_year}` : ''}
</div>
)}
</div>
<ChevronRight size={13} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
</div>
))
)}
</div>
{/* Footer */}
{!loading && items.length > 0 && total > items.length && (
<div style={{
padding: '10px 20px',
borderTop: '1px solid var(--color-border)',
fontSize: 11, color: 'var(--color-text-muted)', textAlign: 'center',
}}>
Showing {items.length} of {total}
</div>
)}
</motion.div>
);
}
/* ─── Clickable Tag with count badge ─────────────────────────────────────── */
interface ClickableTagProps {
label: string;
count: number | undefined;
accentColor: string;
accentBg: string;
onClick: () => void;
}
function ClickableTag({ label, count, accentColor, accentBg, onClick }: ClickableTagProps) {
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '3px 8px 3px 8px',
borderRadius: 6,
border: `1px solid ${hovered ? accentColor : 'var(--color-border)'}`,
backgroundColor: hovered ? accentBg : 'var(--color-surface-el)',
color: hovered ? accentColor : 'var(--color-text-secondary)',
fontSize: 11, fontWeight: 500, cursor: 'pointer',
transition: 'all 0.15s ease',
textDecoration: hovered ? 'underline' : 'none',
}}
>
{label}
{count !== undefined && count > 0 && (
<span style={{
fontSize: 9, fontWeight: 700, padding: '1px 4px', borderRadius: 10,
backgroundColor: hovered ? accentColor : 'rgba(113,113,122,0.3)',
color: hovered ? '#fff' : 'var(--color-text-muted)',
minWidth: 16, textAlign: 'center',
transition: 'all 0.15s ease',
}}>
{count}
</span>
)}
</button>
);
}
/* ─── Clickable Employer Row ─────────────────────────────────────────────── */
interface ClickableEmployerProps {
wh: WorkHistory;
coworkerCount: number | undefined;
onClickEmployer: () => void;
}
function ClickableEmployer({ wh, coworkerCount, onClickEmployer }: ClickableEmployerProps) {
const [hovered, setHovered] = useState(false);
return (
<div
style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}
onClick={onClickEmployer}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
title={`See who else worked at ${wh.company_name}`}
>
<Building2 size={11} style={{ color: hovered ? '#10b981' : 'var(--color-text-muted)', transition: 'color 0.15s' }} />
<span style={{
fontSize: 12,
color: hovered ? '#10b981' : 'var(--color-text-secondary)',
textDecoration: hovered ? 'underline' : 'none',
transition: 'color 0.15s',
fontWeight: 500,
}}>
{wh.company_name}
</span>
{wh.matched_org_name && (
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 3,
backgroundColor: 'rgba(99,102,241,0.15)', color: '#6366f1', fontWeight: 600,
}}>
ORG LINKED
</span>
)}
{coworkerCount !== undefined && coworkerCount > 0 && (
<span style={{
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 10,
backgroundColor: hovered ? 'rgba(16,185,129,0.2)' : 'rgba(113,113,122,0.2)',
color: hovered ? '#10b981' : 'var(--color-text-muted)',
transition: 'all 0.15s ease',
}}>
{coworkerCount} colleague{coworkerCount !== 1 ? 's' : ''}
</span>
)}
</div>
);
}
/* ─── ContactsTab ────────────────────────────────────────────────────────── */
export default function ContactsTab() {
const { addToast } = useToast();
const [contacts, setContacts] = useState<Contact[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<ContactDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [stats, setStats] = useState<EnrichStats | null>(null);
const [showImport, setShowImport] = useState(false);
const [importing, setImporting] = useState(false);
const [enriching, setEnriching] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
// Slide panel state
const [slidePanel, setSlidePanel] = useState<{
open: boolean;
title: string;
subtitle?: string;
mode: 'coworkers' | 'skill' | 'school' | 'group';
items: RelatedContact[];
total: number;
loading: boolean;
}>({ open: false, title: '', mode: 'coworkers', items: [], total: 0, loading: false });
/* ─── Fetch contacts ─────────────────────────────────────────────────── */
const fetchContacts = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ limit: '100' });
if (search) params.set('q', search);
if (statusFilter) params.set('enrichment_status', statusFilter);
const res = await fetch(`/api/contacts?${params}`);
const json = await res.json();
setContacts(json.contacts || []);
setTotal(json.total || 0);
} catch { addToast('Failed to load contacts', 'error'); }
setLoading(false);
}, [search, statusFilter, addToast]);
useEffect(() => { fetchContacts(); }, [fetchContacts]);
/* ─── Fetch stats ────────────────────────────────────────────────────── */
useEffect(() => {
fetch('/api/contacts/enrich')
.then(r => r.json())
.then(j => { if (j.stats) setStats(j.stats); })
.catch(() => {});
}, []);
/* ─── Fetch detail ───────────────────────────────────────────────────── */
const loadDetail = useCallback(async (id: string) => {
setSelectedId(id);
setDetailLoading(true);
setSlidePanel(p => ({ ...p, open: false }));
try {
const res = await fetch(`/api/contacts/${id}`);
const json = await res.json();
setDetail(json);
} catch { addToast('Failed to load contact detail', 'error'); }
setDetailLoading(false);
}, [addToast]);
/* ─── Slide panel openers ────────────────────────────────────────────── */
const openCoworkers = useCallback(async (wh: WorkHistory, contactId: string) => {
const startYear = wh.start_date ? yearFromDate(wh.start_date) : '';
const endYear = wh.is_current ? '' : (wh.end_date ? yearFromDate(wh.end_date) : '');
setSlidePanel({
open: true,
title: `Colleagues at ${wh.company_name}`,
subtitle: `Who else worked there${startYear ? ` ${startYear}–${endYear || 'Present'}` : ''}`,
mode: 'coworkers',
items: [],
total: 0,
loading: true,
});
try {
const params = new URLSearchParams({ org: wh.company_name, exclude_id: contactId });
if (startYear) params.set('start', startYear);
if (endYear) params.set('end', endYear);
const res = await fetch(`/api/contacts/coworkers?${params}`);
const json = await res.json();
setSlidePanel(p => ({ ...p, items: json.coworkers || [], total: json.total || 0, loading: false }));
} catch {
setSlidePanel(p => ({ ...p, loading: false }));
}
}, []);
const openRelated = useCallback(async (type: 'skill' | 'school' | 'group', value: string, contactId: string) => {
const titleMap = { skill: `Contacts with skill`, school: `Alumni of`, group: `Members of` };
const modeMap: Record<string, 'skill' | 'school' | 'group'> = { skill: 'skill', school: 'school', group: 'group' };
setSlidePanel({
open: true,
title: `${titleMap[type]}: ${value}`,
subtitle: undefined,
mode: modeMap[type],
items: [],
total: 0,
loading: true,
});
try {
const params = new URLSearchParams({ type, value, exclude_id: contactId });
const res = await fetch(`/api/contacts/related?${params}`);
const json = await res.json();
setSlidePanel(p => ({ ...p, items: json.contacts || [], total: json.total || 0, loading: false }));
} catch {
setSlidePanel(p => ({ ...p, loading: false }));
}
}, []);
/* ─── Import CSV ─────────────────────────────────────────────────────── */
const handleImport = async (file: File) => {
setImporting(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/contacts/import', { method: 'POST', body: fd });
const json = await res.json();
if (res.ok) {
addToast(`Imported ${json.imported} contacts (${json.skipped} skipped, ${json.org_matches} org matches)`, 'success');
setShowImport(false);
fetchContacts();
} else {
addToast(json.error || 'Import failed', 'error');
}
} catch { addToast('Import failed', 'error'); }
setImporting(false);
};
/* ─── Enrich batch ───────────────────────────────────────────────────── */
const handleEnrichBatch = async () => {
setEnriching(true);
try {
const res = await fetch('/api/contacts/enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ batch: true, limit: 10 }),
});
const json = await res.json();
if (res.ok) {
addToast(`Enriched ${json.enriched}/${json.total} contacts`, 'success');
fetchContacts();
} else {
addToast(json.error || 'Enrichment failed', 'error');
}
} catch { addToast('Enrichment failed', 'error'); }
setEnriching(false);
};
/* ─── Enrich single ──────────────────────────────────────────────────── */
const handleEnrichSingle = async (contactId: string) => {
try {
const res = await fetch('/api/contacts/enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contact_id: contactId }),
});
const json = await res.json();
if (res.ok && json.results?.[0]?.enriched) {
addToast('Contact enriched successfully', 'success');
loadDetail(contactId);
fetchContacts();
} else {
addToast('Enrichment found no data', 'warning');
}
} catch { addToast('Enrichment failed', 'error'); }
};
/* ─── Render ─────────────────────────────────────────────────────────── */
return (
<div style={{ display: 'flex', height: '100%', overflow: 'hidden', position: 'relative' }}>
{/* ── Left: Contact List ─────────────────────────────────────────── */}
<div style={{
width: 380, minWidth: 380, display: 'flex', flexDirection: 'column',
borderRight: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)',
}}>
{/* Stats bar */}
{stats && (
<div style={{
padding: '8px 16px', display: 'flex', gap: 16,
borderBottom: '1px solid var(--color-border)',
fontSize: 11, color: 'var(--color-text-muted)',
}}>
<span>{stats.total} total</span>
<span style={{ color: '#eab308' }}>{stats.pending} pending</span>
<span style={{ color: '#22c55e' }}>{stats.enriched} enriched</span>
<span style={{ color: '#ef4444' }}>{stats.failed} failed</span>
</div>
)}
{/* Search + actions */}
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, position: 'relative' }}>
<Search size={14} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
<input
className="input"
placeholder="Search contacts..."
value={search}
onChange={e => setSearch(e.target.value)}
style={{ paddingLeft: 32, fontSize: 13, height: 34 }}
/>
</div>
<select
className="input"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
style={{ width: 90, fontSize: 12, height: 34 }}
>
<option value="">All</option>
<option value="pending">Pending</option>
<option value="enriched">Enriched</option>
<option value="failed">Failed</option>
</select>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" onClick={() => setShowImport(true)} style={{ flex: 1, gap: 6 }}>
<Upload size={13} /> Import CSV
</button>
<button
className="btn btn-secondary btn-sm"
onClick={handleEnrichBatch}
disabled={enriching}
style={{ flex: 1, gap: 6 }}
>
{enriching ? <Loader2 size={13} className="spinner" /> : <Sparkles size={13} />}
Enrich All
</button>
</div>
</div>
{/* Contact list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 8px 8px' }}>
{loading ? (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--color-text-muted)' }}>
<Loader2 size={20} className="spinner" style={{ margin: '0 auto 8px' }} />
Loading contacts...
</div>
) : contacts.length === 0 ? (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--color-text-muted)' }}>
<UserCircle size={32} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<div style={{ fontSize: 13 }}>No contacts found</div>
<div style={{ fontSize: 11, marginTop: 4 }}>Import a LinkedIn CSV to get started</div>
</div>
) : (
contacts.map(c => (
<motion.div
key={c.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
onClick={() => loadDetail(c.id)}
style={{
padding: '10px 12px',
borderRadius: 8,
cursor: 'pointer',
marginBottom: 2,
backgroundColor: selectedId === c.id ? 'rgba(16, 185, 129, 0.12)' : 'transparent',
borderLeft: selectedId === c.id ? '3px solid #10b981' : '3px solid transparent',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => { if (selectedId !== c.id) e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={e => { if (selectedId !== c.id) e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: '50%',
background: avatarGradient(c.first_name + c.last_name),
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 13, fontWeight: 700, flexShrink: 0,
}}>
{c.first_name[0]}{c.last_name[0]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.first_name} {c.last_name}
</div>
{c.headline ? (
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.headline}
</div>
) : c.company ? (
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', lineHeight: 1.3 }}>
{c.position ? `${c.position} at ` : ''}{c.company}
</div>
) : null}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
<span style={{
fontSize: 9, fontWeight: 600, padding: '2px 6px', borderRadius: 4,
backgroundColor: STATUS_COLORS[c.enrichment_status]?.bg || 'rgba(113,113,122,0.15)',
color: STATUS_COLORS[c.enrichment_status]?.text || '#71717a',
textTransform: 'uppercase', letterSpacing: '0.04em',
}}>
{c.enrichment_status}
</span>
{c.matched_org_name && (
<span style={{ fontSize: 9, color: '#6366f1', display: 'flex', alignItems: 'center', gap: 2 }}>
<Building2 size={9} /> Org matched
</span>
)}
</div>
</div>
</motion.div>
))
)}
{total > contacts.length && (
<div style={{ padding: '8px 12px', fontSize: 11, color: 'var(--color-text-muted)', textAlign: 'center' }}>
Showing {contacts.length} of {total}
</div>
)}
</div>
</div>
{/* ── Right: Detail Panel ────────────────────────────────────────── */}
<div style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--color-bg)' }}>
{!selectedId ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--color-text-muted)' }}>
<div style={{ textAlign: 'center' }}>
<Users size={40} style={{ margin: '0 auto 12px', opacity: 0.3 }} />
<div style={{ fontSize: 14, fontWeight: 500 }}>Select a contact</div>
<div style={{ fontSize: 12, marginTop: 4 }}>Choose from the list or import a CSV</div>
</div>
</div>
) : detailLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Loader2 size={24} className="spinner" style={{ color: 'var(--color-primary)' }} />
</div>
) : detail ? (
<ContactDetailPanel
detail={detail}
onEnrich={() => handleEnrichSingle(detail.contact.id)}
onSelectContact={(id) => loadDetail(id)}
onOpenCoworkers={(wh) => openCoworkers(wh, detail.contact.id)}
onOpenRelated={(type, value) => openRelated(type, value, detail.contact.id)}
/>
) : null}
</div>
{/* ── Slide Panel Overlay ─────────────────────────────────────────── */}
<AnimatePresence>
{slidePanel.open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSlidePanel(p => ({ ...p, open: false }))}
style={{
position: 'fixed', inset: 0, zIndex: 199,
backgroundColor: 'rgba(0,0,0,0.4)',
}}
/>
<SlidePanel
title={slidePanel.title}
subtitle={slidePanel.subtitle}
items={slidePanel.items}
total={slidePanel.total}
loading={slidePanel.loading}
onClose={() => setSlidePanel(p => ({ ...p, open: false }))}
onSelectContact={(id) => loadDetail(id)}
mode={slidePanel.mode}
/>
</>
)}
</AnimatePresence>
{/* ── Import Modal ───────────────────────────────────────────────── */}
{showImport && (
<ResizableModal
modalId="contacts-import-csv"
title="Import LinkedIn CSV"
onClose={() => setShowImport(false)}
defaultWidth={520}
>
<div
onClick={() => fileRef.current?.click()}
style={{
border: '2px dashed var(--color-border)',
borderRadius: 12, padding: 32, textAlign: 'center', cursor: 'pointer',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--color-primary)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
>
<Upload size={32} style={{ margin: '0 auto 8px', color: 'var(--color-text-muted)' }} />
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--color-text)' }}>Click to upload CSV</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
LinkedIn Connections.csv (First Name, Last Name, Email, Company, Position)
</div>
</div>
<input
ref={fileRef}
type="file"
accept=".csv"
style={{ display: 'none' }}
onChange={e => {
const file = e.target.files?.[0];
if (file) handleImport(file);
}}
/>
{importing && (
<div style={{ marginTop: 16, display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'center', color: 'var(--color-primary)' }}>
<Loader2 size={16} className="spinner" /> Importing...
</div>
)}
</ResizableModal>
)}
</div>
);
}
/* ─── Contact Detail Panel ───────────────────────────────────────────────── */
interface ContactCounts {
[key: string]: number;
}
function ContactDetailPanel({
detail,
onEnrich,
onSelectContact,
onOpenCoworkers,
onOpenRelated,
}: {
detail: ContactDetail;
onEnrich: () => void;
onSelectContact: (id: string) => void;
onOpenCoworkers: (wh: WorkHistory) => void;
onOpenRelated: (type: 'skill' | 'school' | 'group', value: string) => void;
}) {
const c = detail.contact;
const [counts, setCounts] = useState<ContactCounts>({});
const [countsLoading, setCountsLoading] = useState(false);
// Load counts for all clickable elements
useEffect(() => {
const skills = c.skills || [];
const schools = detail.education.map(e => e.school);
const groups = detail.groups.map(g => g.group_name);
const employers = detail.work_history.map(wh => ({
company_name: wh.company_name,
start_date: wh.start_date,
end_date: wh.is_current ? null : wh.end_date,
}));
if (!skills.length && !schools.length && !groups.length && !employers.length) return;
setCountsLoading(true);
fetch('/api/contacts/counts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contact_id: c.id, skills, schools, groups, employers }),
})
.then(r => r.json())
.then(j => { if (j.counts) setCounts(j.counts); })
.catch(() => {})
.finally(() => setCountsLoading(false));
}, [c.id, c.skills, detail.education, detail.groups, detail.work_history]);
return (
<div style={{ padding: 24, maxWidth: 720 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16, marginBottom: 24 }}>
<div style={{
width: 56, height: 56, borderRadius: '50%',
background: avatarGradient(c.first_name + c.last_name),
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 20, fontWeight: 700, flexShrink: 0,
}}>
{c.first_name[0]}{c.last_name[0]}
</div>
<div style={{ flex: 1 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.2 }}>
{c.first_name} {c.last_name}
</h2>
{c.headline && (
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginTop: 2 }}>{c.headline}</div>
)}
<div style={{ display: 'flex', gap: 12, marginTop: 8, flexWrap: 'wrap' }}>
{c.location && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 4 }}>
<MapPin size={11} /> {c.location}
</span>
)}
{c.company && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 4 }}>
<Building2 size={11} /> {c.company}
</span>
)}
{c.connected_on && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 4 }}>
<Clock size={11} /> Connected {formatDate(c.connected_on)}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{c.linkedin_url && (
<a href={c.linkedin_url} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm" title="LinkedIn">
<ExternalLink size={14} />
</a>
)}
{c.enrichment_status !== 'enriched' && (
<button className="btn btn-primary btn-sm" onClick={onEnrich} style={{ gap: 4 }}>
<Sparkles size={13} /> Enrich
</button>
)}
</div>
</div>
{/* Summary */}
{c.summary && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 6 }}>SUMMARY</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>{c.summary}</div>
</div>
)}
{/* Org Match */}
{c.matched_org_name && (
<div className="card" style={{ padding: 12, marginBottom: 16, borderLeft: '3px solid #6366f1' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Building2 size={16} style={{ color: '#6366f1' }} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#6366f1' }}>Matched Organization</div>
<div style={{ fontSize: 13, color: 'var(--color-text)' }}>{c.matched_org_name}</div>
</div>
<span style={{
fontSize: 9, fontWeight: 700, padding: '2px 8px', borderRadius: 4,
backgroundColor: 'rgba(99,102,241,0.2)', color: '#6366f1',
textTransform: 'uppercase', letterSpacing: '0.06em',
}}>
ORG LINKED
</span>
</div>
</div>
)}
{/* Skills — clickable */}
{c.skills && c.skills.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 8,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<Tag size={12} /> SKILLS
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 400 }}>
— click to find contacts with same skill
</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{c.skills.map((s, i) => (
<ClickableTag
key={i}
label={s}
count={counts[`skill:${s}`]}
accentColor="#6366f1"
accentBg="rgba(99,102,241,0.12)"
onClick={() => onOpenRelated('skill', s)}
/>
))}
</div>
</div>
)}
{/* Work History Timeline — clickable employers */}
{detail.work_history.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 12,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<Briefcase size={12} /> WORK HISTORY ({detail.work_history.length})
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 400 }}>
— click employer to find colleagues
</span>
</div>
<div style={{ position: 'relative', paddingLeft: 20 }}>
{/* Timeline line */}
<div style={{
position: 'absolute', left: 7, top: 6, bottom: 6,
width: 2, backgroundColor: 'var(--color-border)',
}} />
{detail.work_history.map((wh, i) => (
<div key={wh.id} style={{ position: 'relative', marginBottom: i < detail.work_history.length - 1 ? 16 : 0 }}>
{/* Dot */}
<div style={{
position: 'absolute', left: -16, top: 5,
width: 10, height: 10, borderRadius: '50%',
backgroundColor: wh.is_current ? '#22c55e' : 'var(--color-surface-el)',
border: `2px solid ${wh.is_current ? '#22c55e' : 'var(--color-border)'}`,
}} />
<div className="card" style={{ padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
{wh.title || 'Role'}
</div>
<ClickableEmployer
wh={wh}
coworkerCount={counts[`employer:${wh.company_name}`]}
onClickEmployer={() => onOpenCoworkers(wh)}
/>
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{yearFromDate(wh.start_date)}{wh.start_date ? ' – ' : ''}{wh.is_current ? 'Present' : yearFromDate(wh.end_date)}
</div>
</div>
{wh.description && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 6, lineHeight: 1.4 }}>
{wh.description}
</div>
)}
{wh.location && (
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', marginTop: 4, display: 'flex', alignItems: 'center', gap: 3 }}>
<MapPin size={9} /> {wh.location}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Education — clickable schools */}
{detail.education.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 8,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<GraduationCap size={12} /> EDUCATION ({detail.education.length})
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 400 }}>
— click school to find alumni
</span>
</div>
{detail.education.map(edu => (
<ClickableEducationCard
key={edu.id}
edu={edu}
count={counts[`school:${edu.school}`]}
onClick={() => onOpenRelated('school', edu.school)}
/>
))}
</div>
)}
{/* Groups — clickable */}
{detail.groups.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 8,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<Users size={12} /> GROUPS ({detail.groups.length})
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 400 }}>
— click to find other members
</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{detail.groups.map(g => (
<ClickableTag
key={g.id}
label={g.group_name}
count={counts[`group:${g.group_name}`]}
accentColor="#f59e0b"
accentBg="rgba(245,158,11,0.12)"
onClick={() => onOpenRelated('group', g.group_name)}
/>
))}
</div>
</div>
)}
{/* Existing Coworkers (from contact record) */}
{detail.coworkers.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<Users size={12} /> CONNECTIONS ({detail.coworkers.length})
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{detail.coworkers.map(cw => (
<div
key={cw.id}
onClick={() => onSelectContact(cw.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
borderRadius: 8, cursor: 'pointer', transition: 'background-color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: avatarGradient(cw.first_name + cw.last_name),
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
}}>
{cw.first_name[0]}{cw.last_name[0]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)' }}>
{cw.first_name} {cw.last_name}
</div>
{(cw.headline || cw.company) && (
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{cw.headline || cw.company}
</div>
)}
</div>
<ChevronRight size={14} style={{ color: 'var(--color-text-muted)' }} />
</div>
))}
</div>
</div>
)}
{/* Mind Map Links */}
{detail.links && detail.links.length > 0 && (
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<Globe size={12} /> MIND MAP CONNECTIONS ({detail.links.length})
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{detail.links.map((l, i) => (
<span key={i} className="badge" style={{
fontSize: 10,
backgroundColor: l.link_type === 'employed_at' ? 'rgba(99,102,241,0.15)' :
l.link_type === 'co_workers' ? 'rgba(244,114,182,0.15)' :
l.link_type === 'alumni' ? 'rgba(168,85,247,0.15)' :
'rgba(113,113,122,0.15)',
color: l.link_type === 'employed_at' ? '#6366f1' :
l.link_type === 'co_workers' ? '#f472b6' :
l.link_type === 'alumni' ? '#a855f7' :
'#71717a',
}}>
{l.link_type.replace(/_/g, ' ')} → {l.linked_type}
</span>
))}
</div>
</div>
)}
</div>
);
}
/* ─── Clickable Education Card ───────────────────────────────────────────── */
function ClickableEducationCard({
edu,
count,
onClick,
}: {
edu: Education;
count: number | undefined;
onClick: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<div
className="card"
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
padding: 12,
marginBottom: 6,
cursor: 'pointer',
borderLeft: hovered ? '3px solid #a855f7' : '3px solid transparent',
transition: 'all 0.15s ease',
backgroundColor: hovered ? 'var(--color-surface-el)' : undefined,
}}
title={`Find alumni of ${edu.school}`}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{
fontSize: 13, fontWeight: 600,
color: hovered ? '#a855f7' : 'var(--color-text)',
textDecoration: hovered ? 'underline' : 'none',
transition: 'color 0.15s',
}}>
{edu.school}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginTop: 2 }}>
{[edu.degree, edu.field_of_study].filter(Boolean).join(' in ')}
{edu.start_year && edu.end_year ? ` (${edu.start_year}–${edu.end_year})` : edu.end_year ? ` (${edu.end_year})` : ''}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{count !== undefined && count > 0 && (
<span style={{
fontSize: 9, fontWeight: 700, padding: '2px 6px', borderRadius: 10,
backgroundColor: hovered ? 'rgba(168,85,247,0.2)' : 'rgba(113,113,122,0.2)',
color: hovered ? '#a855f7' : 'var(--color-text-muted)',
transition: 'all 0.15s ease',
}}>
{count} alumni
</span>
)}
<GraduationCap
size={13}
style={{ color: hovered ? '#a855f7' : 'var(--color-text-muted)', transition: 'color 0.15s' }}
/>
</div>
</div>
</div>
);
}