EMAILOUT / frontend /src /pages /Contacts.jsx
Seth
update
9ed5724
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 = (
<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>
);
}