Seth Cursor commited on
Commit
bbafea0
·
1 Parent(s): 6e90916

Co-authored-by: Cursor <cursoragent@cursor.com>

.gitignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ .venv/
8
+ venv/
9
+ ENV/
10
+
11
+ # Environment / local
12
+ .env
13
+ .env.local
14
+ *.db-journal
15
+
16
+ # Node (if used locally)
17
+ node_modules/
18
+ frontend/node_modules/
19
+
20
+ # OS
21
+ .DS_Store
backend/app/__pycache__/__init__.cpython-314.pyc DELETED
Binary file (159 Bytes)
 
backend/app/__pycache__/database.cpython-314.pyc DELETED
Binary file (6.03 kB)
 
backend/app/__pycache__/gpt_service.cpython-314.pyc DELETED
Binary file (63.9 kB)
 
backend/app/__pycache__/main.cpython-314.pyc DELETED
Binary file (99.2 kB)
 
backend/app/__pycache__/models.cpython-314.pyc DELETED
Binary file (4.93 kB)
 
backend/app/main.py CHANGED
@@ -37,6 +37,7 @@ from .models import (
37
  SmartleadRunResponse,
38
  CrmLeadPatchRequest,
39
  BulkLeadIdsRequest,
 
40
  CrmDealPatchRequest,
41
  ContactCreateRequest,
42
  )
@@ -338,6 +339,55 @@ def _move_lead_to_contacts_core(db: Session, lead: CrmLead) -> dict:
338
  return {"ok": True, "contact_id": contact.id, "message": "Contact created"}
339
 
340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  def _deal_name_from_lead(lead: CrmLead) -> str:
342
  person = " ".join(filter(None, [lead.first_name or "", lead.last_name or ""])).strip()
343
  if lead.company_name and person:
@@ -618,6 +668,44 @@ async def create_contact(body: ContactCreateRequest, db: Session = Depends(get_d
618
  }
619
 
620
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  @app.get("/api/contacts/{contact_id}")
622
  async def get_contact(contact_id: int, db: Session = Depends(get_db)):
623
  contact = db.query(Contact).filter(Contact.id == contact_id).first()
 
37
  SmartleadRunResponse,
38
  CrmLeadPatchRequest,
39
  BulkLeadIdsRequest,
40
+ BulkContactIdsRequest,
41
  CrmDealPatchRequest,
42
  ContactCreateRequest,
43
  )
 
339
  return {"ok": True, "contact_id": contact.id, "message": "Contact created"}
340
 
341
 
342
+ CONTACTS_TO_LEADS_CAMPAIGN_ID = "contacts_import"
343
+ CONTACTS_TO_LEADS_CAMPAIGN_NAME = "Contacts"
344
+
345
+
346
+ def _convert_contact_to_lead_core(db: Session, contact: Contact) -> dict:
347
+ """
348
+ Create a CrmLead row from a Contact and delete the contact (move into Leads).
349
+ Skips if a lead with the same email already exists.
350
+ """
351
+ email = (_contact_value(contact, "email") or "").strip()
352
+ if not email:
353
+ return {"ok": False, "error": "Contact has no email"}
354
+ dup = (
355
+ db.query(CrmLead)
356
+ .filter(func.lower(CrmLead.email) == email.lower())
357
+ .first()
358
+ )
359
+ if dup:
360
+ return {"ok": False, "error": "A lead with this email already exists"}
361
+ fn = _contact_value(contact, "first_name") or ""
362
+ ln = _contact_value(contact, "last_name") or ""
363
+ co = _contact_value(contact, "company") or ""
364
+ ti = _contact_value(contact, "title") or ""
365
+ raw = {
366
+ "source": "contacts_convert",
367
+ "former_contact_id": contact.id,
368
+ }
369
+ lead = CrmLead(
370
+ smartlead_lead_id=f"from-contact-{contact.id}",
371
+ campaign_id=CONTACTS_TO_LEADS_CAMPAIGN_ID,
372
+ campaign_name=CONTACTS_TO_LEADS_CAMPAIGN_NAME,
373
+ email=email,
374
+ first_name=fn,
375
+ last_name=ln,
376
+ company_name=co,
377
+ title=ti,
378
+ last_reply_subject="",
379
+ last_reply_body="Added from Contacts",
380
+ last_reply_at=datetime.utcnow(),
381
+ crm_status="new_lead",
382
+ contact_id=None,
383
+ raw_webhook=raw,
384
+ )
385
+ db.add(lead)
386
+ db.flush()
387
+ db.delete(contact)
388
+ return {"ok": True, "lead_id": lead.id}
389
+
390
+
391
  def _deal_name_from_lead(lead: CrmLead) -> str:
392
  person = " ".join(filter(None, [lead.first_name or "", lead.last_name or ""])).strip()
393
  if lead.company_name and person:
 
668
  }
669
 
670
 
671
+ @app.post("/api/contacts/bulk-delete")
672
+ async def bulk_delete_contacts(body: BulkContactIdsRequest, db: Session = Depends(get_db)):
673
+ if not body.contact_ids:
674
+ raise HTTPException(status_code=400, detail="contact_ids required")
675
+ ids = list({int(x) for x in body.contact_ids})
676
+ deleted = (
677
+ db.query(Contact)
678
+ .filter(Contact.id.in_(ids))
679
+ .delete(synchronize_session=False)
680
+ )
681
+ db.commit()
682
+ return {"deleted": deleted}
683
+
684
+
685
+ @app.post("/api/contacts/bulk-convert-to-leads")
686
+ async def bulk_convert_contacts_to_leads(body: BulkContactIdsRequest, db: Session = Depends(get_db)):
687
+ """
688
+ For each contact: create a CrmLead (campaign «Contacts») and delete the contact.
689
+ Fails per-row if the contact has no email or a lead with the same email already exists.
690
+ """
691
+ if not body.contact_ids:
692
+ raise HTTPException(status_code=400, detail="contact_ids required")
693
+ converted = 0
694
+ errors: List[dict] = []
695
+ for cid in body.contact_ids:
696
+ contact = db.query(Contact).filter(Contact.id == int(cid)).first()
697
+ if not contact:
698
+ errors.append({"contact_id": cid, "error": "not found"})
699
+ continue
700
+ r = _convert_contact_to_lead_core(db, contact)
701
+ if not r["ok"]:
702
+ errors.append({"contact_id": cid, "error": r.get("error", "failed")})
703
+ else:
704
+ converted += 1
705
+ db.commit()
706
+ return {"converted": converted, "errors": errors}
707
+
708
+
709
  @app.get("/api/contacts/{contact_id}")
710
  async def get_contact(contact_id: int, db: Session = Depends(get_db)):
711
  contact = db.query(Contact).filter(Contact.id == contact_id).first()
backend/app/models.py CHANGED
@@ -49,6 +49,10 @@ class BulkLeadIdsRequest(BaseModel):
49
  lead_ids: List[int]
50
 
51
 
 
 
 
 
52
  class CrmDealPatchRequest(BaseModel):
53
  stage: Optional[str] = None
54
  deal_value: Optional[int] = None
 
49
  lead_ids: List[int]
50
 
51
 
52
+ class BulkContactIdsRequest(BaseModel):
53
+ contact_ids: List[int]
54
+
55
+
56
  class CrmDealPatchRequest(BaseModel):
57
  stage: Optional[str] = None
58
  deal_value: Optional[int] = None
frontend/src/pages/Contacts.jsx CHANGED
@@ -1,4 +1,5 @@
1
- import React, { useEffect, useRef, useState } from 'react';
 
2
  import {
3
  Users,
4
  Mail,
@@ -13,6 +14,8 @@ import {
13
  Check,
14
  X,
15
  Sparkles,
 
 
16
  } from 'lucide-react';
17
  import { Input } from '@/components/ui/input';
18
  import { Badge } from '@/components/ui/badge';
@@ -21,8 +24,10 @@ import { Button } from '@/components/ui/button';
21
  import AppShell from '@/components/layout/AppShell';
22
  import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
23
  import SlideOverPanel from '@/components/workspace/SlideOverPanel';
 
24
 
25
  export default function Contacts() {
 
26
  const [contacts, setContacts] = useState([]);
27
  const [fields, setFields] = useState([]);
28
  const [selectedContact, setSelectedContact] = useState(null);
@@ -55,7 +60,15 @@ export default function Contacts() {
55
  const inlineFirstNameRef = useRef(null);
56
  const [enrichLoading, setEnrichLoading] = useState(false);
57
  const [enrichError, setEnrichError] = useState('');
58
- const [enrichmentReport, setEnrichmentReport] = useState(null);
 
 
 
 
 
 
 
 
59
 
60
  useEffect(() => {
61
  fetchFields();
@@ -142,7 +155,6 @@ export default function Contacts() {
142
  const openContact = async (contact) => {
143
  setSelectedContact(contact);
144
  setSelectedContactDetails(null);
145
- setEnrichmentReport(null);
146
  setEnrichError('');
147
  setSeqLoading(true);
148
  try {
@@ -171,9 +183,138 @@ export default function Contacts() {
171
  setSelectedContactDetails(null);
172
  setSequences([]);
173
  setEnrichError('');
174
- setEnrichmentReport(null);
175
  };
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  const isManualContact =
178
  selectedContactDetails?.source === 'manual' || selectedContact?.source === 'manual';
179
 
@@ -181,7 +322,6 @@ export default function Contacts() {
181
  if (!selectedContact?.id) return;
182
  setEnrichLoading(true);
183
  setEnrichError('');
184
- setEnrichmentReport(null);
185
  try {
186
  const res = await fetch(`/api/contacts/${selectedContact.id}/enrich`, {
187
  method: 'POST',
@@ -193,9 +333,8 @@ export default function Contacts() {
193
  );
194
  return;
195
  }
196
- const { enrichment_report: nextReport, ...contactPayload } = data;
197
  setSelectedContactDetails(contactPayload);
198
- setEnrichmentReport(nextReport ?? null);
199
  setSelectedContact((c) =>
200
  c && c.id === contactPayload.id
201
  ? {
@@ -414,6 +553,7 @@ export default function Contacts() {
414
  sectionCount={total}
415
  sectionOpen={sectionOpen}
416
  onSectionToggle={() => setSectionOpen(!sectionOpen)}
 
417
  >
418
  <div className="overflow-x-auto">
419
  {loading ? (
@@ -430,9 +570,18 @@ export default function Contacts() {
430
  </div>
431
  ) : (
432
  <>
433
- <table className="w-full text-sm min-w-[720px]">
434
  <thead>
435
  <tr className="bg-slate-50 text-left border-b border-slate-200">
 
 
 
 
 
 
 
 
 
436
  <SortHeader field="first_name" label="Name" />
437
  <SortHeader field="email" label="Email" />
438
  <SortHeader field="company" label="Company" />
@@ -445,6 +594,7 @@ export default function Contacts() {
445
  className="border-b border-slate-100 bg-sky-50 shadow-[inset_4px_0_0_0_#10b981]"
446
  onClick={(e) => e.stopPropagation()}
447
  >
 
448
  <td className="px-3 py-2 align-top">
449
  <div className="flex flex-col gap-1.5 min-w-[160px]">
450
  <Input
@@ -561,6 +711,10 @@ export default function Contacts() {
561
  )}
562
  {contacts.map((contact) => {
563
  const active = selectedContact?.id === contact.id;
 
 
 
 
564
  return (
565
  <tr
566
  key={contact.id}
@@ -573,12 +727,22 @@ export default function Contacts() {
573
  openContact(contact);
574
  }
575
  }}
576
- className={`cursor-pointer border-b border-slate-100 ${
 
577
  active ? 'bg-violet-50/80' : 'hover:bg-violet-50/40'
578
- }`}
579
  >
580
- <td className="px-3 py-2 font-medium text-slate-900">
581
- {contact.first_name} {contact.last_name}
 
 
 
 
 
 
 
 
 
582
  </td>
583
  <td className="px-3 py-2 text-slate-700 truncate max-w-[220px]">
584
  {contact.email || '—'}
@@ -759,128 +923,6 @@ export default function Contacts() {
759
  </div>
760
  )}
761
 
762
- {enrichmentReport?.sources ? (
763
- <div className="mb-6 rounded-xl border border-violet-200 bg-violet-50/35 p-4">
764
- <h4 className="text-sm font-semibold text-violet-900 mb-1">
765
- Last Fetch — sources used
766
- </h4>
767
- <p className="text-xs text-slate-600 mb-3">
768
- {enrichmentReport.generated_at_utc
769
- ? `UTC ${enrichmentReport.generated_at_utc}`
770
- : 'Just now'}
771
- {enrichmentReport.openai_merge_model
772
- ? ` · OpenAI merge model: ${enrichmentReport.openai_merge_model}`
773
- : ''}
774
- </p>
775
- <div className="overflow-x-auto">
776
- <table className="w-full text-left text-xs border-collapse">
777
- <thead>
778
- <tr className="border-b border-violet-200 text-slate-500">
779
- <th className="py-1.5 pr-3 font-medium align-top w-[38%]">
780
- Source
781
- </th>
782
- <th className="py-1.5 font-medium align-top">What ran</th>
783
- </tr>
784
- </thead>
785
- <tbody className="text-slate-700">
786
- {(() => {
787
- const g =
788
- enrichmentReport.sources
789
- .gemini_google_search_grounding || {};
790
- let geminiLine = '—';
791
- if (g.status === 'ok' && g.used) {
792
- geminiLine = `Called — ${g.model || 'model unknown'}, ${g.characters_returned ?? 0} chars returned`;
793
- } else if (g.status === 'not_configured') {
794
- geminiLine = `Not used — ${g.detail || 'GEMINI_API_KEY not set'}`;
795
- } else if (g.status === 'disabled') {
796
- geminiLine = `Not used — ${g.detail || 'disabled by env'}`;
797
- } else if (g.status === 'import_error') {
798
- geminiLine = `Not used — ${g.detail || 'google-genai missing'}`;
799
- } else if (g.status === 'empty_response') {
800
- geminiLine = `Called but empty — ${g.detail || ''}`;
801
- } else if (g.status === 'error') {
802
- geminiLine = `API error — ${g.detail || 'unknown'}`;
803
- } else if (g.detail) {
804
- geminiLine = g.detail;
805
- }
806
- const s = enrichmentReport.sources;
807
- const rows = [
808
- {
809
- k: 'gem',
810
- label: 'Gemini + Google Search (grounding)',
811
- val: geminiLine,
812
- },
813
- {
814
- k: 'home',
815
- label: 'Official homepage excerpt',
816
- val: `${s.official_homepage_excerpt?.characters ?? 0} chars${s.official_homepage_excerpt?.included_in_prompt ? ' (in prompt)' : ' (empty / not in prompt)'}`,
817
- },
818
- {
819
- k: 'schema',
820
- label: 'schema.org JSON-LD',
821
- val: `${s.schema_org_json_ld?.characters ?? 0} chars${s.schema_org_json_ld?.included_in_prompt ? ' (in prompt)' : ''}`,
822
- },
823
- {
824
- k: 'pages',
825
- label: 'Extra first-party pages',
826
- val: `${s.extra_first_party_site_pages?.characters ?? 0} chars${s.extra_first_party_site_pages?.included_in_prompt ? ' (in prompt)' : ''}`,
827
- },
828
- {
829
- k: 'gse',
830
- label: 'Google Programmable Search',
831
- val: s.google_programmable_search?.env_configured
832
- ? `${s.google_programmable_search?.characters_in_snippets ?? 0} chars in snippets`
833
- : 'Not configured (set GOOGLE_API_KEY + GOOGLE_CSE_ID)',
834
- },
835
- {
836
- k: 'ddg',
837
- label: 'DuckDuckGo',
838
- val: s.duckduckgo_web?.package_installed
839
- ? `${s.duckduckgo_web?.characters_in_snippets ?? 0} chars in snippets`
840
- : 'Package not installed (duckduckgo_search)',
841
- },
842
- {
843
- k: 'wiki',
844
- label: 'Wikipedia',
845
- val: `${s.wikipedia?.characters ?? 0} chars${s.wikipedia?.included_in_prompt ? ' (in prompt)' : ''}`,
846
- },
847
- {
848
- k: 'merge',
849
- label: 'Merged web block size',
850
- val: `${s.merged_web_snippets_characters ?? 0} chars (what GPT saw as “web”)`,
851
- },
852
- ];
853
- return rows.map((r) => (
854
- <tr key={r.k} className="border-b border-violet-100/80">
855
- <td className="py-1.5 pr-3 align-top text-slate-600">
856
- {r.label}
857
- </td>
858
- <td className="py-1.5 align-top whitespace-pre-wrap">
859
- {r.val}
860
- </td>
861
- </tr>
862
- ));
863
- })()}
864
- </tbody>
865
- </table>
866
- </div>
867
- {enrichmentReport.sources.gemini_google_search_grounding
868
- ?.text_preview ? (
869
- <details className="mt-3 text-xs">
870
- <summary className="cursor-pointer text-violet-800 font-medium">
871
- Gemini brief preview (truncated)
872
- </summary>
873
- <pre className="mt-2 max-h-36 overflow-auto whitespace-pre-wrap rounded-md border border-slate-200 bg-white/90 p-2 text-[11px] text-slate-800">
874
- {
875
- enrichmentReport.sources
876
- .gemini_google_search_grounding.text_preview
877
- }
878
- </pre>
879
- </details>
880
- ) : null}
881
- </div>
882
- ) : null}
883
-
884
  <h4 className="text-sm font-semibold text-slate-800 mb-2 flex items-center gap-2">
885
  <FileText className="h-4 w-4" />
886
  Generated sequences
 
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
  import {
4
  Users,
5
  Mail,
 
14
  Check,
15
  X,
16
  Sparkles,
17
+ Trash2,
18
+ Handshake,
19
  } from 'lucide-react';
20
  import { Input } from '@/components/ui/input';
21
  import { Badge } from '@/components/ui/badge';
 
24
  import AppShell from '@/components/layout/AppShell';
25
  import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
26
  import SlideOverPanel from '@/components/workspace/SlideOverPanel';
27
+ import { cn } from '@/lib/utils';
28
 
29
  export default function Contacts() {
30
+ const navigate = useNavigate();
31
  const [contacts, setContacts] = useState([]);
32
  const [fields, setFields] = useState([]);
33
  const [selectedContact, setSelectedContact] = useState(null);
 
60
  const inlineFirstNameRef = useRef(null);
61
  const [enrichLoading, setEnrichLoading] = useState(false);
62
  const [enrichError, setEnrichError] = useState('');
63
+ const [rowSelection, setRowSelection] = useState({});
64
+ const [bulkBusy, setBulkBusy] = useState(null);
65
+
66
+ const selectedIds = useMemo(
67
+ () => Object.keys(rowSelection).filter((id) => rowSelection[id]).map(Number),
68
+ [rowSelection]
69
+ );
70
+
71
+ const allPageSelected = contacts.length > 0 && contacts.every((c) => rowSelection[c.id]);
72
 
73
  useEffect(() => {
74
  fetchFields();
 
155
  const openContact = async (contact) => {
156
  setSelectedContact(contact);
157
  setSelectedContactDetails(null);
 
158
  setEnrichError('');
159
  setSeqLoading(true);
160
  try {
 
183
  setSelectedContactDetails(null);
184
  setSequences([]);
185
  setEnrichError('');
 
186
  };
187
 
188
+ const toggleRow = (id) => {
189
+ setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }));
190
+ };
191
+
192
+ const toggleAllPage = () => {
193
+ if (allPageSelected) {
194
+ setRowSelection((prev) => {
195
+ const next = { ...prev };
196
+ contacts.forEach((c) => {
197
+ delete next[c.id];
198
+ });
199
+ return next;
200
+ });
201
+ } else {
202
+ setRowSelection((prev) => {
203
+ const next = { ...prev };
204
+ contacts.forEach((c) => {
205
+ next[c.id] = true;
206
+ });
207
+ return next;
208
+ });
209
+ }
210
+ };
211
+
212
+ const runBulkAction = async (action) => {
213
+ if (selectedIds.length === 0) return;
214
+ const touched = new Set(selectedIds);
215
+ setBulkBusy(action);
216
+ try {
217
+ if (action === 'delete') {
218
+ if (
219
+ !window.confirm(
220
+ `Delete ${selectedIds.length} contact(s)? This cannot be undone.`
221
+ )
222
+ ) {
223
+ return;
224
+ }
225
+ const res = await fetch('/api/contacts/bulk-delete', {
226
+ method: 'POST',
227
+ headers: { 'Content-Type': 'application/json' },
228
+ body: JSON.stringify({ contact_ids: selectedIds }),
229
+ });
230
+ const data = await res.json().catch(() => ({}));
231
+ if (!res.ok) {
232
+ throw new Error(
233
+ typeof data.detail === 'string' ? data.detail : 'Delete failed'
234
+ );
235
+ }
236
+ } else if (action === 'leads') {
237
+ const res = await fetch('/api/contacts/bulk-convert-to-leads', {
238
+ method: 'POST',
239
+ headers: { 'Content-Type': 'application/json' },
240
+ body: JSON.stringify({ contact_ids: selectedIds }),
241
+ });
242
+ const data = await res.json().catch(() => ({}));
243
+ if (!res.ok) {
244
+ throw new Error(
245
+ typeof data.detail === 'string' ? data.detail : 'Convert failed'
246
+ );
247
+ }
248
+ const conv = data.converted ?? 0;
249
+ if (conv === 0) {
250
+ alert(
251
+ data.errors?.length
252
+ ? 'No contacts were converted. Typical reasons: missing email, or a lead with the same email already exists. See console for per-row errors.'
253
+ : 'No contacts were converted.'
254
+ );
255
+ if (data.errors?.length) console.warn(data.errors);
256
+ await fetchContacts();
257
+ return;
258
+ }
259
+ if (data.errors?.length) {
260
+ console.warn(data.errors);
261
+ alert(
262
+ `Converted ${conv} contact(s) to leads. ${data.errors.length} row(s) failed — see console.`
263
+ );
264
+ }
265
+ navigate('/leads');
266
+ }
267
+ if (selectedContact && touched.has(selectedContact.id)) {
268
+ closePanel();
269
+ }
270
+ setRowSelection({});
271
+ await fetchContacts();
272
+ } catch (e) {
273
+ console.error(e);
274
+ alert(e.message || 'Action failed');
275
+ } finally {
276
+ setBulkBusy(null);
277
+ }
278
+ };
279
+
280
+ const bulkDisabled = selectedIds.length === 0 || bulkBusy;
281
+
282
+ const bulkToolbar = (
283
+ <div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-slate-100 bg-slate-50/90">
284
+ <span className="text-xs text-slate-500 mr-2">{selectedIds.length} selected</span>
285
+ <Button
286
+ type="button"
287
+ variant="outline"
288
+ size="icon"
289
+ className="h-9 w-9 shrink-0 text-red-600 border-red-200 hover:bg-red-50"
290
+ title="Delete"
291
+ disabled={bulkDisabled}
292
+ onClick={() => runBulkAction('delete')}
293
+ >
294
+ {bulkBusy === 'delete' ? (
295
+ <Loader2 className="h-4 w-4 animate-spin" />
296
+ ) : (
297
+ <Trash2 className="h-4 w-4" />
298
+ )}
299
+ </Button>
300
+ <Button
301
+ type="button"
302
+ variant="outline"
303
+ size="icon"
304
+ className="h-9 w-9 shrink-0 text-violet-700 border-violet-200 hover:bg-violet-50"
305
+ title="Convert to Leads"
306
+ disabled={bulkDisabled}
307
+ onClick={() => runBulkAction('leads')}
308
+ >
309
+ {bulkBusy === 'leads' ? (
310
+ <Loader2 className="h-4 w-4 animate-spin" />
311
+ ) : (
312
+ <Handshake className="h-4 w-4" />
313
+ )}
314
+ </Button>
315
+ </div>
316
+ );
317
+
318
  const isManualContact =
319
  selectedContactDetails?.source === 'manual' || selectedContact?.source === 'manual';
320
 
 
322
  if (!selectedContact?.id) return;
323
  setEnrichLoading(true);
324
  setEnrichError('');
 
325
  try {
326
  const res = await fetch(`/api/contacts/${selectedContact.id}/enrich`, {
327
  method: 'POST',
 
333
  );
334
  return;
335
  }
336
+ const { enrichment_report: _er, ...contactPayload } = data;
337
  setSelectedContactDetails(contactPayload);
 
338
  setSelectedContact((c) =>
339
  c && c.id === contactPayload.id
340
  ? {
 
553
  sectionCount={total}
554
  sectionOpen={sectionOpen}
555
  onSectionToggle={() => setSectionOpen(!sectionOpen)}
556
+ tableToolbar={bulkToolbar}
557
  >
558
  <div className="overflow-x-auto">
559
  {loading ? (
 
570
  </div>
571
  ) : (
572
  <>
573
+ <table className="w-full text-sm min-w-[760px]">
574
  <thead>
575
  <tr className="bg-slate-50 text-left border-b border-slate-200">
576
+ <th className="px-3 py-2 font-medium w-10">
577
+ <input
578
+ type="checkbox"
579
+ className="rounded border-slate-300"
580
+ checked={allPageSelected}
581
+ onChange={toggleAllPage}
582
+ aria-label="Select all on page"
583
+ />
584
+ </th>
585
  <SortHeader field="first_name" label="Name" />
586
  <SortHeader field="email" label="Email" />
587
  <SortHeader field="company" label="Company" />
 
594
  className="border-b border-slate-100 bg-sky-50 shadow-[inset_4px_0_0_0_#10b981]"
595
  onClick={(e) => e.stopPropagation()}
596
  >
597
+ <td className="px-3 py-2 w-10 align-top" aria-hidden />
598
  <td className="px-3 py-2 align-top">
599
  <div className="flex flex-col gap-1.5 min-w-[160px]">
600
  <Input
 
711
  )}
712
  {contacts.map((contact) => {
713
  const active = selectedContact?.id === contact.id;
714
+ const displayName =
715
+ [contact.first_name, contact.last_name].filter(Boolean).join(' ') ||
716
+ contact.email ||
717
+ '—';
718
  return (
719
  <tr
720
  key={contact.id}
 
727
  openContact(contact);
728
  }
729
  }}
730
+ className={cn(
731
+ 'cursor-pointer border-b border-slate-100',
732
  active ? 'bg-violet-50/80' : 'hover:bg-violet-50/40'
733
+ )}
734
  >
735
+ <td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
736
+ <input
737
+ type="checkbox"
738
+ className="rounded border-slate-300"
739
+ checked={!!rowSelection[contact.id]}
740
+ onChange={() => toggleRow(contact.id)}
741
+ aria-label={`Select ${displayName}`}
742
+ />
743
+ </td>
744
+ <td className="px-3 py-2 font-medium text-violet-800">
745
+ {displayName}
746
  </td>
747
  <td className="px-3 py-2 text-slate-700 truncate max-w-[220px]">
748
  {contact.email || '—'}
 
923
  </div>
924
  )}
925
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
926
  <h4 className="text-sm font-semibold text-slate-800 mb-2 flex items-center gap-2">
927
  <FileText className="h-4 w-4" />
928
  Generated sequences