import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Users, FileText, SlidersHorizontal, Loader2, ArrowUp, ArrowDown, ArrowUpDown, Check, X, Trash2, Handshake, Pencil, Plus, } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; import AppShell from '@/components/layout/AppShell'; import MainTableWorkspace from '@/components/workspace/MainTableWorkspace'; import { apiFetch } from '@/lib/api'; import SlideOverPanel from '@/components/workspace/SlideOverPanel'; import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor'; import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor'; import { EditableCell } from '@/components/workspace/EditableCell'; import { cn } from '@/lib/utils'; function makeFilterRow() { return { id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, field: 'none', op: 'contains', value: '', fromVal: '', toVal: '', }; } export default function Contacts() { const navigate = useNavigate(); const [contacts, setContacts] = useState([]); const [fields, setFields] = useState([]); const [selectedContact, setSelectedContact] = useState(null); const [selectedContactDetails, setSelectedContactDetails] = useState(null); const [sequences, setSequences] = useState([]); const [loading, setLoading] = useState(true); const [seqLoading, setSeqLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [filterRows, setFilterRows] = useState(() => [makeFilterRow()]); const [seedBusy, setSeedBusy] = useState(false); const [sortBy, setSortBy] = useState('created_at'); const [sortDir, setSortDir] = useState('desc'); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState('50'); const [total, setTotal] = useState(0); const [sectionOpen, setSectionOpen] = useState(true); const [addingInline, setAddingInline] = useState(false); const [inlineDraft, setInlineDraft] = useState({ first_name: '', last_name: '', email: '', company: '', title: '', }); const [inlineSaving, setInlineSaving] = useState(false); const [inlineError, setInlineError] = useState(''); const inlineFirstNameRef = useRef(null); const [enrichLoading, setEnrichLoading] = useState(false); const [enrichError, setEnrichError] = useState(''); const [rowSelection, setRowSelection] = useState({}); const [bulkBusy, setBulkBusy] = useState(null); /** Inline inputs only for this row id; pencil toggles. Row click opens slide-over. */ const [tableEditRowId, setTableEditRowId] = useState(null); const selectedIds = useMemo( () => Object.keys(rowSelection).filter((id) => rowSelection[id]).map(Number), [rowSelection] ); const contactCompanyDetailsForEditor = useMemo(() => { if (!selectedContactDetails) return null; const rd = selectedContactDetails.raw_data || {}; return { 'Company Name': rd['Company Name'] ?? selectedContactDetails.company ?? '', 'Company Name for Emails': rd['Company Name for Emails'] ?? '', Industry: rd['Industry'] ?? '', '# Employees': rd['# Employees'] ?? '', 'Annual Revenue': rd['Annual Revenue'] ?? '', 'Last Raised At': rd['Last Raised At'] ?? '', Website: rd['Website'] ?? '', City: rd['City'] ?? '', State: rd['State'] ?? '', Country: rd['Country'] ?? '', }; }, [selectedContactDetails]); const allPageSelected = contacts.length > 0 && contacts.every((c) => rowSelection[c.id]); const filtersKey = useMemo(() => JSON.stringify(filterRows), [filterRows]); const updateFilterRow = (id, patch) => { setFilterRows((rows) => rows.map((r) => (r.id === id ? { ...r, ...patch } : r))); }; const addFilterRow = () => { setFilterRows((rows) => [...rows, makeFilterRow()]); }; const removeFilterRow = (id) => { setFilterRows((rows) => { if (rows.length <= 1) { return [makeFilterRow()]; } return rows.filter((r) => r.id !== id); }); }; useEffect(() => { fetchFields(); fetchContacts(); }, []); useEffect(() => { if (!addingInline) return; const t = requestAnimationFrame(() => { inlineFirstNameRef.current?.focus(); }); return () => cancelAnimationFrame(t); }, [addingInline]); useEffect(() => { if (!addingInline) return; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); setAddingInline(false); setInlineError(''); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [addingInline]); useEffect(() => { const timer = setTimeout(() => fetchContacts(), 250); return () => clearTimeout(timer); }, [searchQuery, filtersKey, sortBy, sortDir, page, pageSize]); useEffect(() => { setPage(1); }, [searchQuery, filtersKey, sortBy, sortDir, pageSize]); const fetchFields = async () => { try { const res = await apiFetch('/api/contact-fields'); if (res.ok) { const data = await res.json(); setFields(data.fields || []); } } catch (e) { console.error('Failed to fetch contact fields:', e); } }; const patchContact = async (contactId, patch) => { try { const res = await apiFetch(`/api/contacts/${contactId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(typeof data.detail === 'string' ? data.detail : 'Update failed'); } setContacts((prev) => prev.map((c) => (c.id === contactId ? { ...c, ...data } : c))); setSelectedContact((c) => (c && c.id === contactId ? { ...c, ...data } : c)); setSelectedContactDetails((d) => (d && d.id === contactId ? { ...d, ...data } : d)); } catch (e) { console.error(e); alert(e.message || 'Could not save changes'); await fetchContacts(); } }; const fetchContacts = async () => { setLoading(true); try { const params = new URLSearchParams(); const pageLimit = Number(pageSize || 50); const pageOffset = (page - 1) * pageLimit; params.set('limit', String(pageLimit)); params.set('offset', String(pageOffset)); params.set('sort_by', sortBy); params.set('sort_dir', sortDir); if (searchQuery.trim()) params.set('search', searchQuery.trim()); const activeFilters = filterRows .filter((r) => r.field && r.field !== 'none') .map((r) => ({ field: r.field, op: r.op, value: r.value, from_value: r.fromVal, to_value: r.toVal, })); if (activeFilters.length > 0) { params.set('filters', JSON.stringify(activeFilters)); } const res = await apiFetch(`/api/contacts?${params.toString()}`); if (res.ok) { const data = await res.json(); setContacts(data.contacts || []); setTotal(data.total || 0); } } catch (e) { console.error('Failed to fetch contacts:', e); } finally { setLoading(false); } }; const seedDemoContacts = async () => { setSeedBusy(true); try { const res = await apiFetch('/api/contacts/seed-demo', { method: 'POST' }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(typeof data.detail === 'string' ? data.detail : 'Could not load demo data'); } await fetchFields(); await fetchContacts(); } catch (e) { console.error(e); alert(e.message || 'Could not load demo data'); } finally { setSeedBusy(false); } }; const openContact = async (contact) => { setTableEditRowId(null); setSelectedContact(contact); setSelectedContactDetails(null); setEnrichError(''); setSeqLoading(true); try { const [detailRes, seqRes] = await Promise.all([ apiFetch(`/api/contacts/${contact.id}`), apiFetch(`/api/contacts/${contact.id}/sequences`), ]); if (detailRes.ok) { const detailData = await detailRes.json(); setSelectedContactDetails(detailData); } if (seqRes.ok) { const seqData = await seqRes.json(); setSequences(seqData.sequences || []); } else setSequences([]); } catch (e) { console.error('Failed to fetch contact sequences:', e); setSequences([]); } finally { setSeqLoading(false); } }; const closePanel = () => { setTableEditRowId(null); setSelectedContact(null); setSelectedContactDetails(null); setSequences([]); setEnrichError(''); }; const toggleRow = (id) => { setRowSelection((prev) => ({ ...prev, [id]: !prev[id] })); }; const toggleAllPage = () => { if (allPageSelected) { setRowSelection((prev) => { const next = { ...prev }; contacts.forEach((c) => { delete next[c.id]; }); return next; }); } else { setRowSelection((prev) => { const next = { ...prev }; contacts.forEach((c) => { next[c.id] = true; }); return next; }); } }; const runBulkAction = async (action) => { if (selectedIds.length === 0) return; const touched = new Set(selectedIds); setBulkBusy(action); try { if (action === 'delete') { if ( !window.confirm( `Delete ${selectedIds.length} contact(s)? This cannot be undone.` ) ) { return; } const res = await apiFetch('/api/contacts/bulk-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contact_ids: selectedIds }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error( typeof data.detail === 'string' ? data.detail : 'Delete failed' ); } } else if (action === 'leads') { const res = await apiFetch('/api/contacts/bulk-convert-to-leads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contact_ids: selectedIds }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error( typeof data.detail === 'string' ? data.detail : 'Convert failed' ); } const conv = data.converted ?? 0; if (conv === 0) { alert( data.errors?.length ? 'No contacts were converted. Typical reasons: missing email, or a lead with the same email already exists. See console for per-row errors.' : 'No contacts were converted.' ); if (data.errors?.length) console.warn(data.errors); await fetchContacts(); return; } if (data.errors?.length) { console.warn(data.errors); alert( `Converted ${conv} contact(s) to leads. ${data.errors.length} row(s) failed — see console.` ); } navigate('/leads'); } if (selectedContact && touched.has(selectedContact.id)) { closePanel(); } setRowSelection({}); await fetchContacts(); } catch (e) { console.error(e); alert(e.message || 'Action failed'); } finally { setBulkBusy(null); } }; const bulkDisabled = selectedIds.length === 0 || bulkBusy; const bulkToolbar = (
{selectedIds.length} selected
); const isManualContact = selectedContactDetails?.source === 'manual' || selectedContact?.source === 'manual'; const enrichContactFromGpt = async () => { if (!selectedContact?.id) return; setEnrichLoading(true); setEnrichError(''); try { const res = await apiFetch(`/api/contacts/${selectedContact.id}/enrich`, { method: 'POST', }); const data = await res.json().catch(() => ({})); if (!res.ok) { setEnrichError( typeof data.detail === 'string' ? data.detail : 'Could not fetch enrichment' ); return; } const { enrichment_report: _er, ...contactPayload } = data; setSelectedContactDetails(contactPayload); setSelectedContact((c) => c && c.id === contactPayload.id ? { ...c, first_name: contactPayload.first_name, last_name: contactPayload.last_name, email: contactPayload.email, company: contactPayload.company, title: contactPayload.title, source: contactPayload.source, } : c ); fetchContacts(); } catch (e) { console.error(e); setEnrichError('Network error'); } finally { setEnrichLoading(false); } }; const beginAddContact = () => { setAddingInline(true); setInlineDraft({ first_name: '', last_name: '', email: '', company: '', title: '', }); setInlineError(''); }; const cancelAddContact = () => { setAddingInline(false); setInlineError(''); }; const saveAddContact = async () => { const email = inlineDraft.email.trim(); if (!email) { setInlineError('Email is required'); return; } if (!email.includes('@')) { setInlineError('Enter a valid email address'); return; } setInlineSaving(true); setInlineError(''); try { const res = await apiFetch('/api/contacts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ first_name: inlineDraft.first_name.trim(), last_name: inlineDraft.last_name.trim(), email, company: inlineDraft.company.trim(), title: inlineDraft.title.trim(), }), }); const data = await res.json().catch(() => ({})); if (res.status === 409) { setInlineError( typeof data.detail === 'string' ? data.detail : 'A contact with this email already exists' ); return; } if (!res.ok) { setInlineError( typeof data.detail === 'string' ? data.detail : 'Could not save contact' ); return; } setAddingInline(false); await fetchContacts(); } catch (e) { console.error(e); setInlineError('Network error'); } finally { setInlineSaving(false); } }; const inlineInputKeyDown = (e) => { if (e.key !== 'Enter' || e.shiftKey) return; e.preventDefault(); saveAddContact(); }; const totalPages = Math.max(1, Math.ceil(total / Number(pageSize || 50))); const toggleSort = (field) => { if (sortBy === field) { setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); } else { setSortBy(field); setSortDir('asc'); } setPage(1); }; const isRowUiTarget = (e) => Boolean( e.target.closest( 'input, textarea, button, a, [role="combobox"], [role="listbox"], [data-radix-collection-item]' ) ); const focusFirstEditableInRow = (tr) => { if (!tr) return; const el = tr.querySelector('input:not([type="checkbox"]):not([type="hidden"]), textarea'); el?.focus?.(); }; const SortHeader = ({ field, label, className = '' }) => { const active = sortBy === field; return ( ); }; const filtersBlock = (
Filters (AND)
{filterRows.map((row) => (
{row.field !== 'none' && ( <>
{(row.op === 'contains' || row.op === 'equals') && ( updateFilterRow(row.id, { value: e.target.value }) } /> )} {(row.op === 'from' || row.op === 'to' || row.op === 'between') && (
updateFilterRow(row.id, { fromVal: e.target.value }) } /> updateFilterRow(row.id, { toVal: e.target.value }) } />
)}
)}
))}
); return ( setSearchQuery(e.target.value), placeholder: 'Search contacts…', }} right={
} filters={filtersBlock} filtersPlacement="left" sectionIcon={Users} sectionTitle="All contacts" sectionCount={total} sectionOpen={sectionOpen} onSectionToggle={() => setSectionOpen(!sectionOpen)} tableToolbar={bulkToolbar} >
{loading ? (
) : contacts.length === 0 && !addingInline ? (

No contacts found

) : ( <> {addingInline && ( e.stopPropagation()} > )} {contacts.map((contact) => { const active = selectedContact?.id === contact.id; const displayName = [contact.first_name, contact.last_name].filter(Boolean).join(' ') || contact.email || '—'; return ( { if (isRowUiTarget(e)) return; setTableEditRowId(null); openContact(contact); }} onKeyDown={(e) => { if (isRowUiTarget(e)) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setTableEditRowId(null); openContact(contact); } }} className={cn( 'cursor-pointer border-b border-slate-100', active ? 'bg-violet-50/80' : 'hover:bg-violet-50/40' )} > ); })}
setInlineDraft((d) => ({ ...d, first_name: e.target.value, })) } onKeyDown={inlineInputKeyDown} disabled={inlineSaving} aria-label="First name" /> setInlineDraft((d) => ({ ...d, last_name: e.target.value, })) } onKeyDown={inlineInputKeyDown} disabled={inlineSaving} aria-label="Last name" />
setInlineDraft((d) => ({ ...d, email: e.target.value })) } onKeyDown={inlineInputKeyDown} disabled={inlineSaving} aria-label="Email" /> setInlineDraft((d) => ({ ...d, company: e.target.value, })) } onKeyDown={inlineInputKeyDown} disabled={inlineSaving} aria-label="Company" />
setInlineDraft((d) => ({ ...d, title: e.target.value, })) } onKeyDown={inlineInputKeyDown} disabled={inlineSaving} aria-label="Title" />
{inlineError ? (

{inlineError}

) : null}
e.stopPropagation()}> toggleRow(contact.id)} aria-label={`Select ${displayName}`} /> e.stopPropagation()} > {tableEditRowId === contact.id ? (
patchContact(contact.id, { first_name: v }) } inputClassName="font-medium text-violet-800" /> patchContact(contact.id, { last_name: v }) } inputClassName="font-medium text-violet-800" />
) : (
{displayName}
)}
{tableEditRowId === contact.id ? ( patchContact(contact.id, { email: v })} /> ) : (
{contact.email || '—'}
)}
{tableEditRowId === contact.id ? ( patchContact(contact.id, { company: v }) } /> ) : (
{contact.company || '—'}
)}
{tableEditRowId === contact.id ? ( patchContact(contact.id, { title: v })} /> ) : (
{contact.title || '—'}
)}
Rows per page Page {page} of {totalPages}
)}
{selectedContact && (
{selectedContactDetails && ( patchContact(selectedContact.id, patch)} disabled={!selectedContact} autoSave /> )}
{contactCompanyDetailsForEditor && selectedContact && ( patchContact(selectedContact.id, patch)} className="mb-0" showFetch={isManualContact} onFetch={enrichContactFromGpt} fetchLoading={enrichLoading} fetchError={enrichError} autoSave /> )}

Generated sequences

{seqLoading ? (

Loading sequences…

) : sequences.length === 0 ? (

No generated sequences for this contact yet.

) : (
{sequences.map((seq) => (
Email {seq.email_number} {seq.product ? `• ${seq.product}` : ''}
Subject: {seq.subject}
{seq.email_content}
))}
)}
)}
); }