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 = (
No contacts found
|
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 ? (
{displayName}
)}
|
{tableEditRowId === contact.id ? (
{contact.email || '—'}
)}
|
{tableEditRowId === contact.id ? (
{contact.company || '—'}
)}
|
{tableEditRowId === contact.id ? (
{contact.title || '—'}
)}
|
Loading sequences…
) : sequences.length === 0 ? (No generated sequences for this contact yet.
) : (