| 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); |
| |
| 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 = ( |
| <div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-slate-100 bg-slate-50/90"> |
| <span className="text-xs text-slate-500 mr-2">{selectedIds.length} selected</span> |
| <Button |
| type="button" |
| variant="outline" |
| size="icon" |
| className="h-9 w-9 shrink-0 text-red-600 border-red-200 hover:bg-red-50" |
| title="Delete" |
| disabled={bulkDisabled} |
| onClick={() => runBulkAction('delete')} |
| > |
| {bulkBusy === 'delete' ? ( |
| <Loader2 className="h-4 w-4 animate-spin" /> |
| ) : ( |
| <Trash2 className="h-4 w-4" /> |
| )} |
| </Button> |
| <Button |
| type="button" |
| variant="outline" |
| size="icon" |
| className="h-9 w-9 shrink-0 text-violet-700 border-violet-200 hover:bg-violet-50" |
| title="Copy to Leads (contacts remain in Contacts)" |
| disabled={bulkDisabled} |
| onClick={() => runBulkAction('leads')} |
| > |
| {bulkBusy === 'leads' ? ( |
| <Loader2 className="h-4 w-4 animate-spin" /> |
| ) : ( |
| <Handshake className="h-4 w-4" /> |
| )} |
| </Button> |
| </div> |
| ); |
|
|
| 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 ( |
| <th className={`px-3 py-2 font-medium ${className}`}> |
| <button |
| type="button" |
| className="inline-flex items-center gap-1.5 text-left text-slate-600 hover:text-violet-700 transition-colors group" |
| onClick={() => toggleSort(field)} |
| > |
| <span>{label}</span> |
| {active ? ( |
| sortDir === 'asc' ? ( |
| <ArrowUp className="h-3.5 w-3.5 text-violet-600 shrink-0" aria-hidden /> |
| ) : ( |
| <ArrowDown className="h-3.5 w-3.5 text-violet-600 shrink-0" aria-hidden /> |
| ) |
| ) : ( |
| <ArrowUpDown |
| className="h-3.5 w-3.5 text-slate-300 group-hover:text-slate-400 shrink-0" |
| aria-hidden |
| /> |
| )} |
| </button> |
| </th> |
| ); |
| }; |
|
|
| const filtersBlock = ( |
| <div className="space-y-2 text-xs"> |
| <div className="flex items-start gap-1.5 text-slate-600 font-medium leading-snug"> |
| <SlidersHorizontal className="h-3.5 w-3.5 mt-0.5 shrink-0" /> |
| <span>Filters (AND)</span> |
| </div> |
| <div className="space-y-2"> |
| {filterRows.map((row) => ( |
| <div |
| key={row.id} |
| className="space-y-2 rounded-lg border border-slate-200 bg-white/90 p-2 shadow-sm" |
| > |
| <div> |
| <label className="sr-only">Field</label> |
| <Select |
| value={row.field} |
| onValueChange={(v) => updateFilterRow(row.id, { field: v })} |
| > |
| <SelectTrigger className="h-8 border-slate-200 bg-white text-xs"> |
| <SelectValue placeholder="Field" /> |
| </SelectTrigger> |
| <SelectContent> |
| <SelectItem value="none">No field filter</SelectItem> |
| {fields.map((field) => ( |
| <SelectItem key={field} value={field}> |
| {field} |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| </div> |
| {row.field !== 'none' && ( |
| <> |
| <div> |
| <label className="sr-only">Operator</label> |
| <Select |
| value={row.op} |
| onValueChange={(v) => updateFilterRow(row.id, { op: v })} |
| > |
| <SelectTrigger className="h-8 border-slate-200 bg-white text-xs"> |
| <SelectValue /> |
| </SelectTrigger> |
| <SelectContent> |
| <SelectItem value="contains">contains</SelectItem> |
| <SelectItem value="equals">equals</SelectItem> |
| <SelectItem value="from">from</SelectItem> |
| <SelectItem value="to">to</SelectItem> |
| <SelectItem value="between">between</SelectItem> |
| </SelectContent> |
| </Select> |
| </div> |
| {(row.op === 'contains' || row.op === 'equals') && ( |
| <Input |
| className="h-8 border-slate-200 bg-white text-xs" |
| placeholder={`“${row.field}”`} |
| value={row.value} |
| onChange={(e) => |
| updateFilterRow(row.id, { value: e.target.value }) |
| } |
| /> |
| )} |
| {(row.op === 'from' || row.op === 'to' || row.op === 'between') && ( |
| <div className="flex flex-col gap-2"> |
| <Input |
| className="h-8 border-slate-200 bg-white text-xs" |
| placeholder="From" |
| value={row.fromVal} |
| onChange={(e) => |
| updateFilterRow(row.id, { fromVal: e.target.value }) |
| } |
| /> |
| <Input |
| className="h-8 border-slate-200 bg-white text-xs" |
| placeholder="To" |
| value={row.toVal} |
| onChange={(e) => |
| updateFilterRow(row.id, { toVal: e.target.value }) |
| } |
| /> |
| </div> |
| )} |
| <div className="flex justify-end pt-0.5"> |
| <Button |
| type="button" |
| variant="ghost" |
| size="icon" |
| className="h-8 w-8 text-slate-400 hover:text-red-600" |
| aria-label="Remove filter row" |
| onClick={() => removeFilterRow(row.id)} |
| > |
| <Trash2 className="h-3.5 w-3.5" /> |
| </Button> |
| </div> |
| </> |
| )} |
| </div> |
| ))} |
| </div> |
| <Button |
| type="button" |
| variant="outline" |
| size="sm" |
| className="h-8 w-full gap-1 text-xs" |
| onClick={addFilterRow} |
| > |
| <Plus className="h-3.5 w-3.5" /> |
| Add filter |
| </Button> |
| </div> |
| ); |
|
|
| return ( |
| <AppShell |
| title="Contacts" |
| subtitle="Apollo CSV contacts stored in SQLite with full field payload." |
| > |
| <MainTableWorkspace |
| primaryAction={{ label: 'New contact', onClick: beginAddContact }} |
| search={{ |
| value: searchQuery, |
| onChange: (e) => setSearchQuery(e.target.value), |
| placeholder: 'Search contacts…', |
| }} |
| right={ |
| <div className="flex flex-wrap items-center gap-2 justify-end"> |
| <Button |
| type="button" |
| variant="secondary" |
| size="sm" |
| onClick={seedDemoContacts} |
| disabled={seedBusy} |
| title="Insert sample Apollo-style contacts for preview" |
| > |
| {seedBusy ? ( |
| <Loader2 className="h-4 w-4 animate-spin shrink-0" aria-hidden /> |
| ) : null} |
| Demo data |
| </Button> |
| <Button variant="outline" size="sm" onClick={() => fetchContacts()}> |
| Refresh |
| </Button> |
| </div> |
| } |
| filters={filtersBlock} |
| filtersPlacement="left" |
| sectionIcon={Users} |
| sectionTitle="All contacts" |
| sectionCount={total} |
| sectionOpen={sectionOpen} |
| onSectionToggle={() => setSectionOpen(!sectionOpen)} |
| tableToolbar={bulkToolbar} |
| > |
| <div className="w-full min-w-0 overflow-x-auto"> |
| {loading ? ( |
| <div className="flex justify-center py-16 text-slate-500"> |
| <Loader2 className="h-8 w-8 animate-spin" /> |
| </div> |
| ) : contacts.length === 0 && !addingInline ? ( |
| <div className="text-center py-16 text-slate-500 space-y-3"> |
| <Users className="h-10 w-10 mx-auto opacity-40" /> |
| <p>No contacts found</p> |
| <div className="flex flex-wrap items-center justify-center gap-2"> |
| <Button |
| type="button" |
| variant="secondary" |
| size="sm" |
| onClick={seedDemoContacts} |
| disabled={seedBusy} |
| > |
| {seedBusy ? ( |
| <Loader2 className="h-4 w-4 animate-spin mr-2" aria-hidden /> |
| ) : null} |
| Load demo rows (preview UI) |
| </Button> |
| <Button variant="outline" size="sm" onClick={beginAddContact}> |
| New contact |
| </Button> |
| </div> |
| </div> |
| ) : ( |
| <> |
| <table className="w-full text-sm min-w-[760px]"> |
| <thead> |
| <tr className="bg-slate-50 text-left border-b border-slate-200"> |
| <th className="px-3 py-2 font-medium w-10"> |
| <input |
| type="checkbox" |
| className="rounded border-slate-300" |
| checked={allPageSelected} |
| onChange={toggleAllPage} |
| aria-label="Select all on page" |
| /> |
| </th> |
| <th className="w-9 px-0 py-2" aria-label="Table edit" /> |
| <SortHeader field="first_name" label="Name" /> |
| <SortHeader field="email" label="Email" /> |
| <SortHeader field="company" label="Company" /> |
| <SortHeader field="title" label="Title" /> |
| </tr> |
| </thead> |
| <tbody> |
| {addingInline && ( |
| <tr |
| className="border-b border-slate-100 bg-sky-50 shadow-[inset_4px_0_0_0_#10b981]" |
| onClick={(e) => e.stopPropagation()} |
| > |
| <td className="px-3 py-2 w-10 align-top" aria-hidden /> |
| <td className="w-9 align-top" aria-hidden /> |
| <td className="px-3 py-2 align-top"> |
| <div className="flex flex-col gap-1.5 min-w-[160px]"> |
| <Input |
| ref={inlineFirstNameRef} |
| className="h-8 border-slate-200 bg-white text-sm" |
| placeholder="First name" |
| value={inlineDraft.first_name} |
| onChange={(e) => |
| setInlineDraft((d) => ({ |
| ...d, |
| first_name: e.target.value, |
| })) |
| } |
| onKeyDown={inlineInputKeyDown} |
| disabled={inlineSaving} |
| aria-label="First name" |
| /> |
| <Input |
| className="h-8 border-slate-200 bg-white text-sm" |
| placeholder="Last name" |
| value={inlineDraft.last_name} |
| onChange={(e) => |
| setInlineDraft((d) => ({ |
| ...d, |
| last_name: e.target.value, |
| })) |
| } |
| onKeyDown={inlineInputKeyDown} |
| disabled={inlineSaving} |
| aria-label="Last name" |
| /> |
| </div> |
| </td> |
| <td className="px-3 py-2 align-top"> |
| <Input |
| className="h-8 border-slate-200 bg-white text-sm max-w-[240px]" |
| type="email" |
| placeholder="Email" |
| value={inlineDraft.email} |
| onChange={(e) => |
| setInlineDraft((d) => ({ ...d, email: e.target.value })) |
| } |
| onKeyDown={inlineInputKeyDown} |
| disabled={inlineSaving} |
| aria-label="Email" |
| /> |
| </td> |
| <td className="px-3 py-2 align-top"> |
| <Input |
| className="h-8 border-slate-200 bg-white text-sm max-w-[200px]" |
| placeholder="Company" |
| value={inlineDraft.company} |
| onChange={(e) => |
| setInlineDraft((d) => ({ |
| ...d, |
| company: e.target.value, |
| })) |
| } |
| onKeyDown={inlineInputKeyDown} |
| disabled={inlineSaving} |
| aria-label="Company" |
| /> |
| </td> |
| <td className="px-3 py-2 align-top"> |
| <div className="flex flex-wrap items-center gap-2"> |
| <Input |
| className="h-8 border-slate-200 bg-white text-sm flex-1 min-w-[120px] max-w-[200px]" |
| placeholder="Title" |
| value={inlineDraft.title} |
| onChange={(e) => |
| setInlineDraft((d) => ({ |
| ...d, |
| title: e.target.value, |
| })) |
| } |
| onKeyDown={inlineInputKeyDown} |
| disabled={inlineSaving} |
| aria-label="Title" |
| /> |
| <div className="flex items-center gap-1 shrink-0"> |
| <Button |
| type="button" |
| size="icon" |
| variant="ghost" |
| className="h-8 w-8 text-emerald-700 hover:text-emerald-800 hover:bg-emerald-50" |
| onClick={saveAddContact} |
| disabled={inlineSaving} |
| aria-label="Save contact" |
| > |
| {inlineSaving ? ( |
| <Loader2 className="h-4 w-4 animate-spin" /> |
| ) : ( |
| <Check className="h-4 w-4" /> |
| )} |
| </Button> |
| <Button |
| type="button" |
| size="icon" |
| variant="ghost" |
| className="h-8 w-8 text-slate-500 hover:text-slate-800" |
| onClick={cancelAddContact} |
| disabled={inlineSaving} |
| aria-label="Cancel" |
| > |
| <X className="h-4 w-4" /> |
| </Button> |
| </div> |
| </div> |
| {inlineError ? ( |
| <p className="text-xs text-red-600 mt-1 w-full">{inlineError}</p> |
| ) : null} |
| </td> |
| </tr> |
| )} |
| {contacts.map((contact) => { |
| const active = selectedContact?.id === contact.id; |
| const displayName = |
| [contact.first_name, contact.last_name].filter(Boolean).join(' ') || |
| contact.email || |
| '—'; |
| return ( |
| <tr |
| key={contact.id} |
| role="button" |
| tabIndex={0} |
| onClick={(e) => { |
| 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' |
| )} |
| > |
| <td className="px-3 py-2" onClick={(e) => e.stopPropagation()}> |
| <input |
| type="checkbox" |
| className="rounded border-slate-300" |
| checked={!!rowSelection[contact.id]} |
| onChange={() => toggleRow(contact.id)} |
| aria-label={`Select ${displayName}`} |
| /> |
| </td> |
| <td |
| className="px-0 py-1.5 align-middle w-9" |
| onClick={(e) => e.stopPropagation()} |
| > |
| <Button |
| type="button" |
| variant="ghost" |
| size="icon" |
| className={cn( |
| 'h-8 w-8 text-slate-500 hover:text-violet-700', |
| tableEditRowId === contact.id && |
| 'bg-violet-100 text-violet-800 hover:bg-violet-100' |
| )} |
| onClick={(e) => { |
| e.stopPropagation(); |
| const tr = e.currentTarget.closest('tr'); |
| const off = tableEditRowId === contact.id; |
| if (off) { |
| setTableEditRowId(null); |
| return; |
| } |
| setTableEditRowId(contact.id); |
| requestAnimationFrame(() => |
| focusFirstEditableInRow(tr) |
| ); |
| }} |
| aria-label={ |
| tableEditRowId === contact.id |
| ? `Stop editing row for ${displayName}` |
| : `Edit fields in row for ${displayName}` |
| } |
| > |
| <Pencil className="h-4 w-4" aria-hidden /> |
| </Button> |
| </td> |
| <td className="px-3 py-2 align-top"> |
| {tableEditRowId === contact.id ? ( |
| <div className="flex flex-col gap-1 min-w-[140px] max-w-[200px]"> |
| <EditableCell |
| value={contact.first_name || ''} |
| onCommit={(v) => |
| patchContact(contact.id, { first_name: v }) |
| } |
| inputClassName="font-medium text-violet-800" |
| /> |
| <EditableCell |
| value={contact.last_name || ''} |
| onCommit={(v) => |
| patchContact(contact.id, { last_name: v }) |
| } |
| inputClassName="font-medium text-violet-800" |
| /> |
| </div> |
| ) : ( |
| <div className="min-h-[2.25rem] py-1 text-sm font-medium text-violet-800 leading-snug"> |
| {displayName} |
| </div> |
| )} |
| </td> |
| <td className="px-3 py-2 align-top max-w-[240px]"> |
| {tableEditRowId === contact.id ? ( |
| <EditableCell |
| type="email" |
| value={contact.email || ''} |
| onCommit={(v) => patchContact(contact.id, { email: v })} |
| /> |
| ) : ( |
| <div className="min-h-[2.25rem] py-1 text-sm text-slate-700 truncate"> |
| {contact.email || '—'} |
| </div> |
| )} |
| </td> |
| <td className="px-3 py-2 align-top max-w-[220px]"> |
| {tableEditRowId === contact.id ? ( |
| <EditableCell |
| value={contact.company || ''} |
| onCommit={(v) => |
| patchContact(contact.id, { company: v }) |
| } |
| /> |
| ) : ( |
| <div className="min-h-[2.25rem] py-1 text-sm text-slate-700 truncate"> |
| {contact.company || '—'} |
| </div> |
| )} |
| </td> |
| <td className="px-3 py-2 align-top max-w-[200px]"> |
| {tableEditRowId === contact.id ? ( |
| <EditableCell |
| value={contact.title || ''} |
| onCommit={(v) => patchContact(contact.id, { title: v })} |
| /> |
| ) : ( |
| <div className="min-h-[2.25rem] py-1 text-sm text-slate-600 truncate"> |
| {contact.title || '—'} |
| </div> |
| )} |
| </td> |
| </tr> |
| ); |
| })} |
| </tbody> |
| </table> |
| <div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 border-t border-slate-100 bg-slate-50/50"> |
| <div className="flex flex-wrap items-center gap-3 text-xs text-slate-600"> |
| <span className="text-slate-500">Rows per page</span> |
| <Select |
| value={pageSize} |
| onValueChange={(v) => { |
| setPageSize(v); |
| setPage(1); |
| }} |
| > |
| <SelectTrigger className="h-8 w-[88px] border-slate-200 bg-white text-xs"> |
| <SelectValue /> |
| </SelectTrigger> |
| <SelectContent> |
| <SelectItem value="25">25</SelectItem> |
| <SelectItem value="50">50</SelectItem> |
| <SelectItem value="100">100</SelectItem> |
| </SelectContent> |
| </Select> |
| <span className="text-slate-500"> |
| Page {page} of {totalPages} |
| </span> |
| </div> |
| <div className="flex items-center gap-2"> |
| <Button |
| variant="outline" |
| size="sm" |
| onClick={() => setPage((p) => Math.max(1, p - 1))} |
| disabled={page <= 1} |
| > |
| Prev |
| </Button> |
| <Button |
| variant="outline" |
| size="sm" |
| onClick={() => setPage((p) => Math.min(totalPages, p + 1))} |
| disabled={page >= totalPages} |
| > |
| Next |
| </Button> |
| </div> |
| </div> |
| </> |
| )} |
| </div> |
| </MainTableWorkspace> |
|
|
| <SlideOverPanel |
| open={!!selectedContact} |
| onClose={closePanel} |
| title={ |
| selectedContactDetails || selectedContact |
| ? `${selectedContactDetails?.first_name || selectedContact?.first_name || ''} ${selectedContactDetails?.last_name || selectedContact?.last_name || ''}`.trim() || |
| 'Contact' |
| : 'Contact' |
| } |
| subtitle={(selectedContactDetails?.email || selectedContact?.email) || ''} |
| widthClassName="max-w-xl" |
| > |
| {selectedContact && ( |
| <div className="flex flex-col"> |
| {selectedContactDetails && ( |
| <ContactIdentityEditor |
| firstName={selectedContactDetails.first_name || ''} |
| lastName={selectedContactDetails.last_name || ''} |
| email={selectedContactDetails.email || ''} |
| title={selectedContactDetails.title || ''} |
| onSave={(patch) => patchContact(selectedContact.id, patch)} |
| disabled={!selectedContact} |
| autoSave |
| /> |
| )} |
| |
| <div className="mt-8 pt-6 border-t border-slate-100"> |
| {contactCompanyDetailsForEditor && selectedContact && ( |
| <CompanyDetailsEditor |
| variant="contact" |
| companyDetails={contactCompanyDetailsForEditor} |
| fallbackCompanyName={ |
| selectedContactDetails?.company || selectedContact.company || '' |
| } |
| onSave={(patch) => patchContact(selectedContact.id, patch)} |
| className="mb-0" |
| showFetch={isManualContact} |
| onFetch={enrichContactFromGpt} |
| fetchLoading={enrichLoading} |
| fetchError={enrichError} |
| autoSave |
| /> |
| )} |
| </div> |
| |
| <div className="mt-8 pt-6 border-t border-slate-100"> |
| <h4 className="text-sm font-semibold text-slate-800 mb-2 flex items-center gap-2"> |
| <FileText className="h-4 w-4" /> |
| Generated sequences |
| </h4> |
| {seqLoading ? ( |
| <p className="text-sm text-slate-500">Loading sequences…</p> |
| ) : sequences.length === 0 ? ( |
| <p className="text-sm text-slate-500">No generated sequences for this contact yet.</p> |
| ) : ( |
| <div className="space-y-3 max-h-[55vh] overflow-y-auto pr-1"> |
| {sequences.map((seq) => ( |
| <div |
| key={seq.id} |
| className="rounded-xl border border-slate-200 p-4 bg-slate-50/50" |
| > |
| <div className="font-medium text-slate-800 mb-2"> |
| Email {seq.email_number} {seq.product ? `• ${seq.product}` : ''} |
| </div> |
| <div className="text-sm mb-2"> |
| <span className="font-semibold text-slate-700">Subject: </span> |
| <span className="text-slate-700">{seq.subject}</span> |
| </div> |
| <div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-700 whitespace-pre-wrap"> |
| {seq.email_content} |
| </div> |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| </div> |
| )} |
| </SlideOverPanel> |
| </AppShell> |
| ); |
| } |
|
|