Seth commited on
Commit
8d2acd1
·
1 Parent(s): c50adfe
backend/app/__pycache__/main.cpython-314.pyc CHANGED
Binary files a/backend/app/__pycache__/main.cpython-314.pyc and b/backend/app/__pycache__/main.cpython-314.pyc differ
 
backend/app/__pycache__/models.cpython-314.pyc ADDED
Binary file (4.93 kB). View file
 
backend/app/database.py CHANGED
@@ -86,13 +86,35 @@ class CrmLead(Base):
86
  last_reply_subject = Column(String)
87
  last_reply_body = Column(Text)
88
  last_reply_at = Column(DateTime, nullable=True)
89
- crm_status = Column(String, default="new_lead") # new_lead|attempted_to_contact|contacted|qualified|unqualified|none
90
  contact_id = Column(Integer, nullable=True) # links to contacts.id after "Move to Contacts"
91
  raw_webhook = Column(JSON)
92
  created_at = Column(DateTime, default=datetime.utcnow)
93
  updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  class SmartleadRun(Base):
97
  __tablename__ = "smartlead_runs"
98
 
 
86
  last_reply_subject = Column(String)
87
  last_reply_body = Column(Text)
88
  last_reply_at = Column(DateTime, nullable=True)
89
+ crm_status = Column(String, default="new_lead") # new_lead|contacted|qualified|unqualified|none
90
  contact_id = Column(Integer, nullable=True) # links to contacts.id after "Move to Contacts"
91
  raw_webhook = Column(JSON)
92
  created_at = Column(DateTime, default=datetime.utcnow)
93
  updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
94
 
95
 
96
+ class CrmDeal(Base):
97
+ """Pipeline deal (often converted from a lead)."""
98
+ __tablename__ = "crm_deals"
99
+
100
+ id = Column(Integer, primary_key=True, index=True)
101
+ name = Column(String, index=True)
102
+ stage = Column(String, default="new") # new|discovery|proposal|negotiation|won|lost
103
+ owner_initials = Column(String, default="")
104
+ deal_value = Column(Integer, nullable=True) # whole USD (nullable)
105
+ contact_display = Column(String, default="") # primary person label
106
+ account_name = Column(String, default="")
107
+ expected_close_date = Column(DateTime, nullable=True)
108
+ close_probability = Column(Integer, default=10) # 0–100
109
+ country = Column(String, default="")
110
+ last_interaction_at = Column(DateTime, nullable=True)
111
+ source_lead_id = Column(Integer, nullable=True)
112
+ source_campaign_name = Column(String, default="")
113
+ contact_id = Column(Integer, nullable=True)
114
+ created_at = Column(DateTime, default=datetime.utcnow)
115
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
116
+
117
+
118
  class SmartleadRun(Base):
119
  __tablename__ = "smartlead_runs"
120
 
backend/app/main.py CHANGED
@@ -18,7 +18,17 @@ import math
18
  import re
19
  from datetime import datetime, timedelta
20
 
21
- from .database import get_db, UploadedFile, Prompt, GeneratedSequence, SmartleadRun, Contact, CrmLead
 
 
 
 
 
 
 
 
 
 
22
  from .models import (
23
  UploadResponse,
24
  PromptSaveRequest,
@@ -26,6 +36,8 @@ from .models import (
26
  SmartleadPushRequest,
27
  SmartleadRunResponse,
28
  CrmLeadPatchRequest,
 
 
29
  )
30
  from .gpt_service import generate_email_sequence
31
  from .smartlead_client import SmartleadClient
@@ -129,14 +141,37 @@ def _to_datetime(val):
129
  CRM_STATUS_ALLOWED = frozenset({
130
  "none",
131
  "new_lead",
132
- "attempted_to_contact",
133
  "contacted",
134
  "qualified",
135
  "unqualified",
136
  })
 
 
 
 
 
 
 
 
137
  SMARTLEAD_IMPORT_FILE_ID = "smartlead-imports"
138
 
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  def _strip_html_simple(text: str) -> str:
141
  if not text:
142
  return ""
@@ -260,6 +295,95 @@ def _crm_lead_to_dict(row: CrmLead) -> dict:
260
  }
261
 
262
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  @app.get("/api/health")
264
  def health():
265
  return {"status": "ok"}
@@ -1155,7 +1279,7 @@ async def seed_demo_leads(db: Session = Depends(get_db)):
1155
  "last_name": "Chen",
1156
  "company_name": "Pacific Rail Partners",
1157
  "title": "Director of Procurement",
1158
- "crm_status": "attempted_to_contact",
1159
  "subject": "Re: Partnership",
1160
  "body": (
1161
  "Not interested right now — we renewed with our incumbent through 2026. "
@@ -1352,50 +1476,213 @@ async def move_lead_to_contacts(lead_id: int, db: Session = Depends(get_db)):
1352
  lead = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
1353
  if not lead:
1354
  raise HTTPException(status_code=404, detail="Lead not found")
1355
- if lead.contact_id:
1356
- return {"contact_id": lead.contact_id, "message": "Already linked to Contacts"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1357
 
1358
- email = (lead.email or "").strip()
1359
- if not email:
1360
- raise HTTPException(status_code=400, detail="Lead has no email")
1361
 
1362
- existing = (
1363
- db.query(Contact)
1364
- .filter(func.lower(Contact.email) == email.lower())
1365
- .first()
 
 
 
 
1366
  )
1367
- if existing:
1368
- lead.contact_id = existing.id
1369
- db.commit()
1370
- return {"contact_id": existing.id, "message": "Linked to existing contact"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1371
 
1372
- raw = {
1373
- "Company Name": lead.company_name or "",
1374
- "Title": lead.title or "",
1375
- "source": "smartlead_reply",
1376
- "smartlead_lead_id": lead.smartlead_lead_id,
1377
- "campaign_id": lead.campaign_id,
1378
- "last_reply_subject": lead.last_reply_subject,
1379
- "last_reply_body": lead.last_reply_body,
1380
- "smartlead_webhook": lead.raw_webhook,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1381
  }
1382
- contact = Contact(
1383
- file_id=SMARTLEAD_IMPORT_FILE_ID,
1384
- row_index=lead_id,
1385
- first_name=lead.first_name or "",
1386
- last_name=lead.last_name or "",
1387
- email=email,
1388
- company=lead.company_name or "",
1389
- title=lead.title or "",
1390
- source="smartlead",
1391
- raw_data=raw,
1392
- )
1393
- db.add(contact)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1394
  db.commit()
1395
- db.refresh(contact)
1396
- lead.contact_id = contact.id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1397
  db.commit()
1398
- return {"contact_id": contact.id, "message": "Contact created"}
1399
 
1400
 
1401
  @app.get("/api/leads/{lead_id}/smartlead-thread")
 
18
  import re
19
  from datetime import datetime, timedelta
20
 
21
+ from .database import (
22
+ get_db,
23
+ SessionLocal,
24
+ UploadedFile,
25
+ Prompt,
26
+ GeneratedSequence,
27
+ SmartleadRun,
28
+ Contact,
29
+ CrmLead,
30
+ CrmDeal,
31
+ )
32
  from .models import (
33
  UploadResponse,
34
  PromptSaveRequest,
 
36
  SmartleadPushRequest,
37
  SmartleadRunResponse,
38
  CrmLeadPatchRequest,
39
+ BulkLeadIdsRequest,
40
+ CrmDealPatchRequest,
41
  )
42
  from .gpt_service import generate_email_sequence
43
  from .smartlead_client import SmartleadClient
 
141
  CRM_STATUS_ALLOWED = frozenset({
142
  "none",
143
  "new_lead",
 
144
  "contacted",
145
  "qualified",
146
  "unqualified",
147
  })
148
+ DEAL_STAGE_ALLOWED = frozenset({
149
+ "new",
150
+ "discovery",
151
+ "proposal",
152
+ "negotiation",
153
+ "won",
154
+ "lost",
155
+ })
156
  SMARTLEAD_IMPORT_FILE_ID = "smartlead-imports"
157
 
158
 
159
+ @app.on_event("startup")
160
+ def _migrate_lead_status_removed_option():
161
+ """Map legacy attempted_to_contact rows to contacted once at startup."""
162
+ db = SessionLocal()
163
+ try:
164
+ db.query(CrmLead).filter(CrmLead.crm_status == "attempted_to_contact").update(
165
+ {CrmLead.crm_status: "contacted"},
166
+ synchronize_session=False,
167
+ )
168
+ db.commit()
169
+ except Exception:
170
+ db.rollback()
171
+ finally:
172
+ db.close()
173
+
174
+
175
  def _strip_html_simple(text: str) -> str:
176
  if not text:
177
  return ""
 
295
  }
296
 
297
 
298
+ def _move_lead_to_contacts_core(db: Session, lead: CrmLead) -> dict:
299
+ if lead.contact_id:
300
+ return {"ok": True, "contact_id": lead.contact_id, "message": "Already linked to Contacts"}
301
+ email = (lead.email or "").strip()
302
+ if not email:
303
+ return {"ok": False, "error": "Lead has no email"}
304
+ existing = (
305
+ db.query(Contact)
306
+ .filter(func.lower(Contact.email) == email.lower())
307
+ .first()
308
+ )
309
+ if existing:
310
+ lead.contact_id = existing.id
311
+ return {"ok": True, "contact_id": existing.id, "message": "Linked to existing contact"}
312
+ raw = {
313
+ "Company Name": lead.company_name or "",
314
+ "Title": lead.title or "",
315
+ "source": "smartlead_reply",
316
+ "smartlead_lead_id": lead.smartlead_lead_id,
317
+ "campaign_id": lead.campaign_id,
318
+ "last_reply_subject": lead.last_reply_subject,
319
+ "last_reply_body": lead.last_reply_body,
320
+ "smartlead_webhook": lead.raw_webhook,
321
+ }
322
+ contact = Contact(
323
+ file_id=SMARTLEAD_IMPORT_FILE_ID,
324
+ row_index=lead.id,
325
+ first_name=lead.first_name or "",
326
+ last_name=lead.last_name or "",
327
+ email=email,
328
+ company=lead.company_name or "",
329
+ title=lead.title or "",
330
+ source="smartlead",
331
+ raw_data=raw,
332
+ )
333
+ db.add(contact)
334
+ db.flush()
335
+ lead.contact_id = contact.id
336
+ return {"ok": True, "contact_id": contact.id, "message": "Contact created"}
337
+
338
+
339
+ def _deal_name_from_lead(lead: CrmLead) -> str:
340
+ person = " ".join(filter(None, [lead.first_name or "", lead.last_name or ""])).strip()
341
+ if lead.company_name and person:
342
+ return f"{lead.company_name} — {person}"
343
+ if lead.company_name:
344
+ return lead.company_name or "Deal"
345
+ if person:
346
+ return person
347
+ return (lead.email or "").strip() or "Untitled deal"
348
+
349
+
350
+ def _owner_initials_from_lead(lead: CrmLead) -> str:
351
+ a = (lead.first_name or "").strip()[:1].upper()
352
+ b = (lead.last_name or "").strip()[:1].upper()
353
+ s = a + b
354
+ return s if s else "?"
355
+
356
+
357
+ def _deal_to_dict(d: CrmDeal) -> dict:
358
+ fv = None
359
+ if d.deal_value is not None and d.close_probability is not None:
360
+ fv = int(round(d.deal_value * (d.close_probability / 100.0)))
361
+ ecd = None
362
+ if d.expected_close_date:
363
+ try:
364
+ ecd = d.expected_close_date.date().isoformat()
365
+ except Exception:
366
+ ecd = d.expected_close_date.isoformat()
367
+ return {
368
+ "id": d.id,
369
+ "name": d.name or "",
370
+ "stage": d.stage or "new",
371
+ "owner_initials": d.owner_initials or "",
372
+ "deal_value": d.deal_value,
373
+ "contact_display": d.contact_display or "",
374
+ "account_name": d.account_name or "",
375
+ "expected_close_date": ecd,
376
+ "close_probability": d.close_probability if d.close_probability is not None else 10,
377
+ "forecast_value": fv,
378
+ "last_interaction_at": d.last_interaction_at.isoformat() if d.last_interaction_at else None,
379
+ "country": d.country or "",
380
+ "source_lead_id": d.source_lead_id,
381
+ "source_campaign_name": d.source_campaign_name or "",
382
+ "contact_id": d.contact_id,
383
+ "created_at": d.created_at.isoformat() if d.created_at else None,
384
+ }
385
+
386
+
387
  @app.get("/api/health")
388
  def health():
389
  return {"status": "ok"}
 
1279
  "last_name": "Chen",
1280
  "company_name": "Pacific Rail Partners",
1281
  "title": "Director of Procurement",
1282
+ "crm_status": "contacted",
1283
  "subject": "Re: Partnership",
1284
  "body": (
1285
  "Not interested right now — we renewed with our incumbent through 2026. "
 
1476
  lead = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
1477
  if not lead:
1478
  raise HTTPException(status_code=404, detail="Lead not found")
1479
+ r = _move_lead_to_contacts_core(db, lead)
1480
+ if not r["ok"]:
1481
+ raise HTTPException(status_code=400, detail=r.get("error", "Move failed"))
1482
+ db.commit()
1483
+ return {"contact_id": r["contact_id"], "message": r["message"]}
1484
+
1485
+
1486
+ @app.post("/api/leads/bulk-move-to-contacts")
1487
+ async def bulk_move_leads_to_contacts(body: BulkLeadIdsRequest, db: Session = Depends(get_db)):
1488
+ if not body.lead_ids:
1489
+ raise HTTPException(status_code=400, detail="lead_ids required")
1490
+ moved = 0
1491
+ errors: List[dict] = []
1492
+ for lid in body.lead_ids:
1493
+ lead = db.query(CrmLead).filter(CrmLead.id == lid).first()
1494
+ if not lead:
1495
+ errors.append({"lead_id": lid, "error": "not found"})
1496
+ continue
1497
+ r = _move_lead_to_contacts_core(db, lead)
1498
+ if not r["ok"]:
1499
+ errors.append({"lead_id": lid, "error": r.get("error", "failed")})
1500
+ else:
1501
+ moved += 1
1502
+ db.commit()
1503
+ return {"moved": moved, "errors": errors}
1504
 
 
 
 
1505
 
1506
+ @app.post("/api/leads/bulk-delete")
1507
+ async def bulk_delete_leads(body: BulkLeadIdsRequest, db: Session = Depends(get_db)):
1508
+ if not body.lead_ids:
1509
+ raise HTTPException(status_code=400, detail="lead_ids required")
1510
+ deleted = (
1511
+ db.query(CrmLead)
1512
+ .filter(CrmLead.id.in_(body.lead_ids))
1513
+ .delete(synchronize_session=False)
1514
  )
1515
+ db.commit()
1516
+ return {"deleted": deleted}
1517
+
1518
+
1519
+ @app.post("/api/deals/from-leads")
1520
+ async def deals_from_leads(body: BulkLeadIdsRequest, db: Session = Depends(get_db)):
1521
+ """Create one deal per selected lead and remove those leads from the Leads table."""
1522
+ if not body.lead_ids:
1523
+ raise HTTPException(status_code=400, detail="lead_ids required")
1524
+ created: List[dict] = []
1525
+ errors: List[dict] = []
1526
+ for lid in body.lead_ids:
1527
+ lead = db.query(CrmLead).filter(CrmLead.id == lid).first()
1528
+ if not lead:
1529
+ errors.append({"lead_id": lid, "error": "not found"})
1530
+ continue
1531
+ person = " ".join(filter(None, [lead.first_name or "", lead.last_name or ""])).strip()
1532
+ deal = CrmDeal(
1533
+ name=_deal_name_from_lead(lead),
1534
+ stage="new",
1535
+ owner_initials=_owner_initials_from_lead(lead),
1536
+ deal_value=None,
1537
+ contact_display=person or (lead.email or ""),
1538
+ account_name=lead.company_name or "",
1539
+ expected_close_date=datetime.utcnow() + timedelta(days=60),
1540
+ close_probability=25,
1541
+ country="",
1542
+ last_interaction_at=lead.last_reply_at or datetime.utcnow(),
1543
+ source_lead_id=lead.id,
1544
+ source_campaign_name=lead.campaign_name or lead.campaign_id or "",
1545
+ contact_id=lead.contact_id,
1546
+ )
1547
+ db.add(deal)
1548
+ db.flush()
1549
+ created.append(_deal_to_dict(deal))
1550
+ db.delete(lead)
1551
+ db.commit()
1552
+ return {"created": created, "errors": errors}
1553
 
1554
+
1555
+ @app.get("/api/deals")
1556
+ async def list_deals(
1557
+ search: str = Query(""),
1558
+ sort_by: str = Query("created_at"),
1559
+ sort_dir: str = Query("desc"),
1560
+ limit: int = Query(100, ge=1, le=500),
1561
+ offset: int = Query(0, ge=0),
1562
+ db: Session = Depends(get_db),
1563
+ ):
1564
+ q = db.query(CrmDeal)
1565
+ if search.strip():
1566
+ term = f"%{search.strip().lower()}%"
1567
+ q = q.filter(
1568
+ or_(
1569
+ func.lower(CrmDeal.name).like(term),
1570
+ func.lower(func.coalesce(CrmDeal.account_name, "")).like(term),
1571
+ func.lower(func.coalesce(CrmDeal.contact_display, "")).like(term),
1572
+ )
1573
+ )
1574
+ total = q.count()
1575
+ col_map = {
1576
+ "created_at": CrmDeal.created_at,
1577
+ "name": CrmDeal.name,
1578
+ "deal_value": CrmDeal.deal_value,
1579
+ "stage": CrmDeal.stage,
1580
+ "last_interaction_at": CrmDeal.last_interaction_at,
1581
  }
1582
+ col = col_map.get(sort_by, CrmDeal.created_at)
1583
+ if sort_dir == "asc":
1584
+ q = q.order_by(col.asc())
1585
+ else:
1586
+ q = q.order_by(col.desc())
1587
+ rows = q.offset(offset).limit(limit).all()
1588
+ return {"total": total, "deals": [_deal_to_dict(r) for r in rows]}
1589
+
1590
+
1591
+ @app.patch("/api/deals/{deal_id}")
1592
+ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, db: Session = Depends(get_db)):
1593
+ row = db.query(CrmDeal).filter(CrmDeal.id == deal_id).first()
1594
+ if not row:
1595
+ raise HTTPException(status_code=404, detail="Deal not found")
1596
+ if body.stage is not None:
1597
+ if body.stage not in DEAL_STAGE_ALLOWED:
1598
+ raise HTTPException(
1599
+ status_code=400,
1600
+ detail=f"stage must be one of: {sorted(DEAL_STAGE_ALLOWED)}",
1601
+ )
1602
+ row.stage = body.stage
1603
+ if body.deal_value is not None:
1604
+ row.deal_value = body.deal_value
1605
+ if body.close_probability is not None:
1606
+ row.close_probability = max(0, min(100, body.close_probability))
1607
+ if body.country is not None:
1608
+ row.country = body.country
1609
+ if body.expected_close_date is not None:
1610
+ ts = _to_datetime(body.expected_close_date)
1611
+ row.expected_close_date = ts
1612
  db.commit()
1613
+ db.refresh(row)
1614
+ return _deal_to_dict(row)
1615
+
1616
+
1617
+ @app.post("/api/deals/seed-demo")
1618
+ async def seed_demo_deals(db: Session = Depends(get_db)):
1619
+ removed = db.query(CrmDeal).filter(CrmDeal.name.like("DEMO: %")).delete(synchronize_session=False)
1620
+ now = datetime.utcnow()
1621
+ samples = [
1622
+ {
1623
+ "name": "DEMO: Ramco — Aus",
1624
+ "stage": "discovery",
1625
+ "owner_initials": "KW",
1626
+ "deal_value": 10000,
1627
+ "contact_display": "KENNETH WON",
1628
+ "account_name": "Quanticsit",
1629
+ "close_probability": 10,
1630
+ "close_in_days": 120,
1631
+ "country": "Australia",
1632
+ },
1633
+ {
1634
+ "name": "DEMO: HT Pilot",
1635
+ "stage": "proposal",
1636
+ "owner_initials": "SL",
1637
+ "deal_value": 1600,
1638
+ "contact_display": "SARAH LEE",
1639
+ "account_name": "HT Logistics",
1640
+ "close_probability": 60,
1641
+ "close_in_days": 45,
1642
+ "country": "Malaysia",
1643
+ },
1644
+ {
1645
+ "name": "DEMO: Northwind rollout",
1646
+ "stage": "negotiation",
1647
+ "owner_initials": "JD",
1648
+ "deal_value": 45000,
1649
+ "contact_display": "JAMES DIAZ",
1650
+ "account_name": "Northwind Trading",
1651
+ "close_probability": 40,
1652
+ "close_in_days": 60,
1653
+ "country": "USA",
1654
+ },
1655
+ {
1656
+ "name": "DEMO: Maple cold chain",
1657
+ "stage": "new",
1658
+ "owner_initials": "AR",
1659
+ "deal_value": None,
1660
+ "contact_display": "ALEX RUIZ",
1661
+ "account_name": "Maple Cold",
1662
+ "close_probability": 15,
1663
+ "close_in_days": 90,
1664
+ "country": "Canada",
1665
+ },
1666
+ ]
1667
+ for s in samples:
1668
+ db.add(
1669
+ CrmDeal(
1670
+ name=s["name"],
1671
+ stage=s["stage"],
1672
+ owner_initials=s["owner_initials"],
1673
+ deal_value=s["deal_value"],
1674
+ contact_display=s["contact_display"],
1675
+ account_name=s["account_name"],
1676
+ expected_close_date=now + timedelta(days=s["close_in_days"]),
1677
+ close_probability=s["close_probability"],
1678
+ country=s["country"],
1679
+ last_interaction_at=now - timedelta(days=1),
1680
+ source_lead_id=None,
1681
+ source_campaign_name="Demo",
1682
+ )
1683
+ )
1684
  db.commit()
1685
+ return {"ok": True, "removed_previous_demo_rows": removed, "inserted": len(samples)}
1686
 
1687
 
1688
  @app.get("/api/leads/{lead_id}/smartlead-thread")
backend/app/models.py CHANGED
@@ -37,6 +37,18 @@ class CrmLeadPatchRequest(BaseModel):
37
  crm_status: str
38
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  class SmartleadRunResponse(BaseModel):
41
  run_id: str
42
  campaign_id: Optional[str] = None
 
37
  crm_status: str
38
 
39
 
40
+ class BulkLeadIdsRequest(BaseModel):
41
+ lead_ids: List[int]
42
+
43
+
44
+ class CrmDealPatchRequest(BaseModel):
45
+ stage: Optional[str] = None
46
+ deal_value: Optional[int] = None
47
+ close_probability: Optional[int] = None
48
+ expected_close_date: Optional[str] = None # ISO date or datetime
49
+ country: Optional[str] = None
50
+
51
+
52
  class SmartleadRunResponse(BaseModel):
53
  run_id: str
54
  campaign_id: Optional[str] = None
frontend/src/App.jsx CHANGED
@@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
3
  import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
4
  import Contacts from "./pages/Contacts";
5
  import Leads from "./pages/Leads";
 
6
  import "./index.css";
7
 
8
  export default function App() {
@@ -12,6 +13,7 @@ export default function App() {
12
  <Route path="/" element={<EmailSequenceGenerator />} />
13
  <Route path="/contacts" element={<Contacts />} />
14
  <Route path="/leads" element={<Leads />} />
 
15
  <Route path="/history" element={<Navigate to="/leads" replace />} />
16
  </Routes>
17
  </BrowserRouter>
 
3
  import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
4
  import Contacts from "./pages/Contacts";
5
  import Leads from "./pages/Leads";
6
+ import Deals from "./pages/Deals";
7
  import "./index.css";
8
 
9
  export default function App() {
 
13
  <Route path="/" element={<EmailSequenceGenerator />} />
14
  <Route path="/contacts" element={<Contacts />} />
15
  <Route path="/leads" element={<Leads />} />
16
+ <Route path="/deals" element={<Deals />} />
17
  <Route path="/history" element={<Navigate to="/leads" replace />} />
18
  </Routes>
19
  </BrowserRouter>
frontend/src/components/layout/AppHeader.jsx CHANGED
@@ -7,6 +7,7 @@ const MENU_ITEMS = [
7
  { label: 'Generator', href: '/' },
8
  { label: 'Contacts', href: '/contacts' },
9
  { label: 'Leads', href: '/leads' },
 
10
  ];
11
 
12
  function pathMatches(locationPath, href) {
 
7
  { label: 'Generator', href: '/' },
8
  { label: 'Contacts', href: '/contacts' },
9
  { label: 'Leads', href: '/leads' },
10
+ { label: 'Deals', href: '/deals' },
11
  ];
12
 
13
  function pathMatches(locationPath, href) {
frontend/src/components/layout/AppShell.jsx CHANGED
@@ -1,12 +1,13 @@
1
  import React from 'react';
2
  import { Link, useLocation } from 'react-router-dom';
3
- import { Zap, LayoutDashboard, Users, Inbox } from 'lucide-react';
4
  import { Button } from "@/components/ui/button";
5
 
6
  const NAV_ITEMS = [
7
  { label: 'Generator', href: '/', icon: LayoutDashboard },
8
  { label: 'Contacts', href: '/contacts', icon: Users },
9
  { label: 'Leads', href: '/leads', icon: Inbox },
 
10
  ];
11
 
12
  function pathMatches(locationPath, href) {
 
1
  import React from 'react';
2
  import { Link, useLocation } from 'react-router-dom';
3
+ import { Zap, LayoutDashboard, Users, Inbox, Handshake } from 'lucide-react';
4
  import { Button } from "@/components/ui/button";
5
 
6
  const NAV_ITEMS = [
7
  { label: 'Generator', href: '/', icon: LayoutDashboard },
8
  { label: 'Contacts', href: '/contacts', icon: Users },
9
  { label: 'Leads', href: '/leads', icon: Inbox },
10
+ { label: 'Deals', href: '/deals', icon: Handshake },
11
  ];
12
 
13
  function pathMatches(locationPath, href) {
frontend/src/pages/Deals.jsx ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ Search,
4
+ ChevronDown,
5
+ ChevronRight,
6
+ Loader2,
7
+ LayoutGrid,
8
+ } from 'lucide-react';
9
+ import { Input } from '@/components/ui/input';
10
+ import { Button } from '@/components/ui/button';
11
+ import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
12
+ import AppShell from '@/components/layout/AppShell';
13
+ import { cn } from '@/lib/utils';
14
+
15
+ const STAGES = [
16
+ { value: 'new', label: 'New', className: 'bg-slate-800 text-white' },
17
+ { value: 'discovery', label: 'Discovery', className: 'bg-cyan-400 text-slate-900' },
18
+ { value: 'proposal', label: 'Proposal', className: 'bg-sky-400 text-slate-900' },
19
+ { value: 'negotiation', label: 'Negotiation', className: 'bg-teal-500 text-white' },
20
+ { value: 'won', label: 'Won', className: 'bg-emerald-500 text-white' },
21
+ { value: 'lost', label: 'Lost', className: 'bg-red-500 text-white' },
22
+ ];
23
+
24
+ function stageMeta(v) {
25
+ return STAGES.find((s) => s.value === v) || STAGES[0];
26
+ }
27
+
28
+ function fmtMoney(n) {
29
+ if (n == null || n === '') return '—';
30
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
31
+ }
32
+
33
+ function fmtDate(iso) {
34
+ if (!iso) return '—';
35
+ const d = new Date(iso);
36
+ if (Number.isNaN(d.getTime())) return iso;
37
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
38
+ }
39
+
40
+ export default function Deals() {
41
+ const [deals, setDeals] = useState([]);
42
+ const [total, setTotal] = useState(0);
43
+ const [loading, setLoading] = useState(true);
44
+ const [search, setSearch] = useState('');
45
+ const [sectionOpen, setSectionOpen] = useState(true);
46
+ const [seedBusy, setSeedBusy] = useState(false);
47
+
48
+ const fetchDeals = useCallback(async () => {
49
+ setLoading(true);
50
+ try {
51
+ const params = new URLSearchParams();
52
+ params.set('limit', '200');
53
+ params.set('offset', '0');
54
+ params.set('sort_by', 'created_at');
55
+ params.set('sort_dir', 'desc');
56
+ if (search.trim()) params.set('search', search.trim());
57
+ const res = await fetch(`/api/deals?${params.toString()}`);
58
+ if (res.ok) {
59
+ const data = await res.json();
60
+ setDeals(data.deals || []);
61
+ setTotal(data.total ?? 0);
62
+ }
63
+ } catch (e) {
64
+ console.error(e);
65
+ } finally {
66
+ setLoading(false);
67
+ }
68
+ }, [search]);
69
+
70
+ useEffect(() => {
71
+ const t = setTimeout(() => fetchDeals(), 250);
72
+ return () => clearTimeout(t);
73
+ }, [fetchDeals]);
74
+
75
+ const seedDemo = async () => {
76
+ setSeedBusy(true);
77
+ try {
78
+ const res = await fetch('/api/deals/seed-demo', { method: 'POST' });
79
+ if (!res.ok) throw new Error('Seed failed');
80
+ await fetchDeals();
81
+ } catch (e) {
82
+ console.error(e);
83
+ alert('Could not load demo deals');
84
+ } finally {
85
+ setSeedBusy(false);
86
+ }
87
+ };
88
+
89
+ const updateStage = async (dealId, stage) => {
90
+ try {
91
+ const res = await fetch(`/api/deals/${dealId}`, {
92
+ method: 'PATCH',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ stage }),
95
+ });
96
+ if (!res.ok) throw new Error('Update failed');
97
+ const updated = await res.json();
98
+ setDeals((prev) => prev.map((d) => (d.id === dealId ? { ...d, ...updated } : d)));
99
+ } catch (e) {
100
+ console.error(e);
101
+ alert('Could not update stage');
102
+ }
103
+ };
104
+
105
+ return (
106
+ <AppShell
107
+ title="Deals"
108
+ subtitle="Pipeline from converted leads — stage, value, and forecast."
109
+ >
110
+ <div className="space-y-4">
111
+ <div className="flex flex-wrap gap-2 border-b border-slate-200 pb-3">
112
+ <button
113
+ type="button"
114
+ className="rounded-full px-4 py-1.5 text-sm font-medium bg-violet-100 text-violet-800"
115
+ >
116
+ Main table
117
+ </button>
118
+ <span className="text-sm text-slate-400 py-1.5 px-2">By Stage</span>
119
+ <span className="text-sm text-slate-400 py-1.5 px-2">Pipeline</span>
120
+ </div>
121
+
122
+ <div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
123
+ <Button size="sm" className="bg-teal-600 hover:bg-teal-700 text-white w-fit">
124
+ New deal
125
+ </Button>
126
+ <div className="flex flex-1 flex-wrap gap-2 justify-end items-center">
127
+ <div className="relative flex-1 min-w-[200px] max-w-md">
128
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
129
+ <Input
130
+ className="pl-9"
131
+ placeholder="Search deals…"
132
+ value={search}
133
+ onChange={(e) => setSearch(e.target.value)}
134
+ />
135
+ </div>
136
+ <Button
137
+ variant="secondary"
138
+ size="sm"
139
+ onClick={() => seedDemo()}
140
+ disabled={seedBusy}
141
+ >
142
+ {seedBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Demo data'}
143
+ </Button>
144
+ <Button variant="outline" size="sm" onClick={() => fetchDeals()}>
145
+ Refresh
146
+ </Button>
147
+ </div>
148
+ </div>
149
+
150
+ <div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden">
151
+ <button
152
+ type="button"
153
+ onClick={() => setSectionOpen(!sectionOpen)}
154
+ className="w-full flex items-center gap-2 px-4 py-3 text-left font-semibold text-slate-800 border-b border-slate-100 hover:bg-slate-50"
155
+ >
156
+ {sectionOpen ? (
157
+ <ChevronDown className="h-5 w-5 text-violet-600" />
158
+ ) : (
159
+ <ChevronRight className="h-5 w-5 text-violet-600" />
160
+ )}
161
+ <LayoutGrid className="h-5 w-5 text-slate-500" />
162
+ Active Deals
163
+ <span className="text-slate-400 font-normal text-sm ml-2">{total} total</span>
164
+ </button>
165
+
166
+ {sectionOpen && (
167
+ <div className="overflow-x-auto">
168
+ {loading ? (
169
+ <div className="flex justify-center py-16 text-slate-500">
170
+ <Loader2 className="h-8 w-8 animate-spin" />
171
+ </div>
172
+ ) : deals.length === 0 ? (
173
+ <div className="text-center py-16 text-slate-500 space-y-3">
174
+ <p>No deals yet. Convert leads from the Leads page, or load demo data.</p>
175
+ <Button size="sm" variant="secondary" onClick={() => seedDemo()} disabled={seedBusy}>
176
+ Load demo deals
177
+ </Button>
178
+ </div>
179
+ ) : (
180
+ <table className="w-full text-sm min-w-[960px]">
181
+ <thead>
182
+ <tr className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
183
+ <th className="px-2 py-2 w-10" />
184
+ <th className="px-3 py-2 font-medium">Deal</th>
185
+ <th className="px-3 py-2 font-medium">Stage</th>
186
+ <th className="px-3 py-2 font-medium">Owner</th>
187
+ <th className="px-3 py-2 font-medium">Deal value</th>
188
+ <th className="px-3 py-2 font-medium">Contacts</th>
189
+ <th className="px-3 py-2 font-medium">Accounts</th>
190
+ <th className="px-3 py-2 font-medium">Expected close</th>
191
+ <th className="px-3 py-2 font-medium">Close %</th>
192
+ <th className="px-3 py-2 font-medium">Forecast</th>
193
+ <th className="px-3 py-2 font-medium">Last interaction</th>
194
+ <th className="px-3 py-2 font-medium">Country</th>
195
+ </tr>
196
+ </thead>
197
+ <tbody>
198
+ {deals.map((deal) => {
199
+ const meta = stageMeta(deal.stage);
200
+ const safeStage = STAGES.some((s) => s.value === deal.stage)
201
+ ? deal.stage
202
+ : 'new';
203
+ return (
204
+ <tr
205
+ key={deal.id}
206
+ className="border-b border-slate-100 hover:bg-violet-50/40"
207
+ >
208
+ <td className="px-2 py-2">
209
+ <input type="checkbox" className="rounded border-slate-300" />
210
+ </td>
211
+ <td className="px-3 py-2 font-medium text-slate-900">{deal.name}</td>
212
+ <td className="px-3 py-2">
213
+ <Select
214
+ value={safeStage}
215
+ onValueChange={(v) => updateStage(deal.id, v)}
216
+ >
217
+ <SelectTrigger
218
+ className={cn(
219
+ 'h-8 w-[min(100%,150px)] border-slate-200 shadow-none'
220
+ )}
221
+ >
222
+ <span
223
+ className={cn(
224
+ 'rounded-full px-2 py-0.5 text-xs font-medium',
225
+ meta.className
226
+ )}
227
+ >
228
+ {meta.label}
229
+ </span>
230
+ </SelectTrigger>
231
+ <SelectContent className="min-w-[180px]">
232
+ {STAGES.map((s) => (
233
+ <SelectItem key={s.value} value={s.value}>
234
+ <span
235
+ className={cn(
236
+ 'rounded-full px-2 py-0.5 text-xs font-medium inline-block',
237
+ s.className
238
+ )}
239
+ >
240
+ {s.label}
241
+ </span>
242
+ </SelectItem>
243
+ ))}
244
+ </SelectContent>
245
+ </Select>
246
+ </td>
247
+ <td className="px-3 py-2">
248
+ <div className="h-8 w-8 rounded-full bg-violet-100 text-violet-700 text-xs font-semibold flex items-center justify-center border border-violet-200">
249
+ {deal.owner_initials || '?'}
250
+ </div>
251
+ </td>
252
+ <td className="px-3 py-2 text-slate-700 tabular-nums">
253
+ {fmtMoney(deal.deal_value)}
254
+ </td>
255
+ <td className="px-3 py-2">
256
+ {deal.contact_display ? (
257
+ <span className="inline-flex rounded-md bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-800">
258
+ {deal.contact_display}
259
+ </span>
260
+ ) : (
261
+ '—'
262
+ )}
263
+ </td>
264
+ <td className="px-3 py-2">
265
+ {deal.account_name ? (
266
+ <span className="inline-flex rounded-md bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-800">
267
+ {deal.account_name}
268
+ </span>
269
+ ) : (
270
+ '—'
271
+ )}
272
+ </td>
273
+ <td className="px-3 py-2 text-slate-600">
274
+ {fmtDate(deal.expected_close_date)}
275
+ </td>
276
+ <td className="px-3 py-2 tabular-nums text-slate-700">
277
+ {deal.close_probability ?? '—'}%
278
+ </td>
279
+ <td className="px-3 py-2 tabular-nums text-slate-700">
280
+ {fmtMoney(deal.forecast_value)}
281
+ </td>
282
+ <td className="px-3 py-2 text-slate-600">
283
+ {fmtDate(deal.last_interaction_at)}
284
+ </td>
285
+ <td className="px-3 py-2 text-slate-700">{deal.country || '—'}</td>
286
+ </tr>
287
+ );
288
+ })}
289
+ </tbody>
290
+ </table>
291
+ )}
292
+ </div>
293
+ )}
294
+ </div>
295
+ </div>
296
+ </AppShell>
297
+ );
298
+ }
frontend/src/pages/Leads.jsx CHANGED
@@ -1,4 +1,5 @@
1
  import React, { useCallback, useEffect, useMemo, useState } from 'react';
 
2
  import {
3
  Inbox,
4
  Search,
@@ -9,6 +10,9 @@ import {
9
  Mail,
10
  ExternalLink,
11
  Loader2,
 
 
 
12
  } from 'lucide-react';
13
  import { Input } from '@/components/ui/input';
14
  import { Button } from '@/components/ui/button';
@@ -19,17 +23,23 @@ import { cn } from '@/lib/utils';
19
  const CRM_STATUSES = [
20
  { value: 'none', label: '—', className: 'bg-slate-300 text-slate-800' },
21
  { value: 'new_lead', label: 'New Lead', className: 'bg-amber-200 text-amber-950' },
22
- { value: 'attempted_to_contact', label: 'Attempted to contact', className: 'bg-rose-100 text-rose-800' },
23
  { value: 'contacted', label: 'Contacted', className: 'bg-orange-500 text-white' },
24
  { value: 'qualified', label: 'Qualified', className: 'bg-lime-500 text-white' },
25
  { value: 'unqualified', label: 'Unqualified', className: 'bg-fuchsia-900 text-white' },
26
  ];
27
 
28
  function statusMeta(value) {
29
- return CRM_STATUSES.find((s) => s.value === value) || CRM_STATUSES[1];
 
 
 
 
 
 
30
  }
31
 
32
  export default function Leads() {
 
33
  const [leads, setLeads] = useState([]);
34
  const [total, setTotal] = useState(0);
35
  const [loading, setLoading] = useState(true);
@@ -39,8 +49,16 @@ export default function Leads() {
39
  const [selected, setSelected] = useState(null);
40
  const [threadLoading, setThreadLoading] = useState(false);
41
  const [threadData, setThreadData] = useState(null);
42
- const [moveBusy, setMoveBusy] = useState(null);
43
  const [seedBusy, setSeedBusy] = useState(false);
 
 
 
 
 
 
 
 
 
44
 
45
  const webhookUrl = useMemo(() => {
46
  if (typeof window === 'undefined') return '';
@@ -111,24 +129,6 @@ export default function Leads() {
111
  }
112
  };
113
 
114
- const moveToContacts = async (leadId) => {
115
- setMoveBusy(leadId);
116
- try {
117
- const res = await fetch(`/api/leads/${leadId}/move-to-contacts`, { method: 'POST' });
118
- const data = await res.json().catch(() => ({}));
119
- if (!res.ok) throw new Error(data.detail || res.statusText);
120
- await fetchLeads();
121
- setSelected((s) =>
122
- s && s.id === leadId ? { ...s, contact_id: data.contact_id } : s
123
- );
124
- } catch (e) {
125
- console.error(e);
126
- alert(e.message || 'Move failed');
127
- } finally {
128
- setMoveBusy(null);
129
- }
130
- };
131
-
132
  const loadThread = async (leadId) => {
133
  setThreadLoading(true);
134
  setThreadData(null);
@@ -159,6 +159,89 @@ export default function Leads() {
159
  }
160
  };
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  return (
163
  <AppShell
164
  title="Leads"
@@ -259,151 +342,191 @@ export default function Leads() {
259
  </Button>
260
  </div>
261
  ) : (
262
- <table className="w-full text-sm">
263
- <thead>
264
- <tr className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
265
- <th className="px-3 py-2 font-medium w-10" />
266
- <th className="px-3 py-2 font-medium">Lead</th>
267
- <th className="px-3 py-2 font-medium">Status</th>
268
- <th className="px-3 py-2 font-medium">Create a contact</th>
269
- <th className="px-3 py-2 font-medium">Company</th>
270
- <th className="px-3 py-2 font-medium">Title</th>
271
- <th className="px-3 py-2 font-medium">Email</th>
272
- <th className="px-3 py-2 font-medium">Last reply</th>
273
- </tr>
274
- </thead>
275
- <tbody>
276
- {leads.map((lead) => {
277
- const meta = statusMeta(lead.crm_status);
278
- const displayName =
279
- [lead.first_name, lead.last_name].filter(Boolean).join(' ') ||
280
- lead.email;
281
- const busy = moveBusy === lead.id;
282
- return (
283
- <tr
284
- key={lead.id}
285
- className={cn(
286
- 'border-b border-slate-100 hover:bg-violet-50/40',
287
- selected?.id === lead.id && 'bg-violet-50/60'
288
- )}
289
- >
290
- <td className="px-3 py-2">
291
- <input type="checkbox" className="rounded border-slate-300" />
292
- </td>
293
- <td className="px-3 py-2">
294
- <button
295
- type="button"
296
- onClick={() => openDetail(lead)}
297
- className="text-left text-violet-700 hover:underline font-medium"
298
- >
299
- {displayName}
300
- </button>
301
- </td>
302
- <td className="px-3 py-2">
303
- <Select
304
- value={
305
- CRM_STATUSES.some(
306
- (s) => s.value === lead.crm_status
307
- )
308
- ? lead.crm_status
309
- : 'new_lead'
310
- }
311
- onValueChange={(v) => updateStatus(lead.id, v)}
312
- >
313
- <SelectTrigger
314
- className={cn(
315
- 'h-9 w-[min(100%,200px)] border-slate-200',
316
- 'shadow-none'
317
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  >
319
- <span
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  className={cn(
321
- 'rounded-full px-2.5 py-0.5 text-xs font-medium',
322
- meta.className
323
  )}
324
  >
325
- {meta.label}
326
- </span>
327
- </SelectTrigger>
328
- <SelectContent className="min-w-[240px]">
329
- {CRM_STATUSES.map((s) => (
330
- <SelectItem
331
- key={s.value}
332
- value={s.value}
333
- className="cursor-pointer"
334
  >
335
- <span
336
- className={cn(
337
- 'rounded-full px-2.5 py-0.5 text-xs font-medium inline-block',
338
- s.className
339
- )}
 
 
 
 
340
  >
341
- {s.label}
342
- </span>
343
- </SelectItem>
344
- ))}
345
- </SelectContent>
346
- </Select>
347
- </td>
348
- <td className="px-3 py-2">
349
- {lead.contact_id ? (
350
- <a
351
- href={`/contacts`}
352
- className="text-xs text-emerald-700 font-medium"
353
- >
354
- In Contacts (#{lead.contact_id})
355
- </a>
356
- ) : (
357
- <Button
358
- size="sm"
359
- className="bg-emerald-600 hover:bg-emerald-700 text-white h-8"
360
- disabled={busy}
361
- onClick={() => moveToContacts(lead.id)}
362
- >
363
- {busy ? (
364
- <Loader2 className="h-4 w-4 animate-spin" />
365
- ) : (
366
- 'Move to Contacts'
367
- )}
368
- </Button>
369
- )}
370
- </td>
371
- <td className="px-3 py-2 text-slate-700">
372
- <span className="inline-flex items-center gap-1">
373
- <Building2 className="h-3.5 w-3.5 text-slate-400" />
374
- {lead.company_name || '—'}
375
- </span>
376
- </td>
377
- <td className="px-3 py-2 text-slate-700">
378
- <span className="inline-flex items-center gap-1">
379
- <Briefcase className="h-3.5 w-3.5 text-slate-400" />
380
- {lead.title || '—'}
381
- </span>
382
- </td>
383
- <td className="px-3 py-2">
384
- {lead.email ? (
385
- <a
386
- href={`mailto:${lead.email}`}
387
- className="text-violet-600 hover:underline inline-flex items-center gap-1"
388
- >
389
- <Mail className="h-3.5 w-3.5" />
390
- {lead.email}
391
- </a>
392
- ) : (
393
- '—'
394
- )}
395
- </td>
396
- <td className="px-3 py-2 text-slate-600 max-w-[200px] truncate" title={lead.last_reply_body}>
397
- {lead.last_reply_body
398
- ? lead.last_reply_body.slice(0, 80) +
399
- (lead.last_reply_body.length > 80 ? '…' : '')
400
- : '—'}
401
- </td>
402
- </tr>
403
- );
404
- })}
405
- </tbody>
406
- </table>
407
  )}
408
  </div>
409
  )}
 
1
  import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
  import {
4
  Inbox,
5
  Search,
 
10
  Mail,
11
  ExternalLink,
12
  Loader2,
13
+ UserPlus,
14
+ Trash2,
15
+ Handshake,
16
  } from 'lucide-react';
17
  import { Input } from '@/components/ui/input';
18
  import { Button } from '@/components/ui/button';
 
23
  const CRM_STATUSES = [
24
  { value: 'none', label: '—', className: 'bg-slate-300 text-slate-800' },
25
  { value: 'new_lead', label: 'New Lead', className: 'bg-amber-200 text-amber-950' },
 
26
  { value: 'contacted', label: 'Contacted', className: 'bg-orange-500 text-white' },
27
  { value: 'qualified', label: 'Qualified', className: 'bg-lime-500 text-white' },
28
  { value: 'unqualified', label: 'Unqualified', className: 'bg-fuchsia-900 text-white' },
29
  ];
30
 
31
  function statusMeta(value) {
32
+ const v = value === 'attempted_to_contact' ? 'contacted' : value;
33
+ return CRM_STATUSES.find((s) => s.value === v) || CRM_STATUSES[1];
34
+ }
35
+
36
+ function statusValueForSelect(leadStatus) {
37
+ const v = leadStatus === 'attempted_to_contact' ? 'contacted' : leadStatus;
38
+ return CRM_STATUSES.some((s) => s.value === v) ? v : 'new_lead';
39
  }
40
 
41
  export default function Leads() {
42
+ const navigate = useNavigate();
43
  const [leads, setLeads] = useState([]);
44
  const [total, setTotal] = useState(0);
45
  const [loading, setLoading] = useState(true);
 
49
  const [selected, setSelected] = useState(null);
50
  const [threadLoading, setThreadLoading] = useState(false);
51
  const [threadData, setThreadData] = useState(null);
 
52
  const [seedBusy, setSeedBusy] = useState(false);
53
+ const [rowSelection, setRowSelection] = useState({});
54
+ const [bulkBusy, setBulkBusy] = useState(null);
55
+
56
+ const selectedIds = useMemo(
57
+ () => Object.keys(rowSelection).filter((id) => rowSelection[id]).map(Number),
58
+ [rowSelection]
59
+ );
60
+
61
+ const allPageSelected = leads.length > 0 && leads.every((l) => rowSelection[l.id]);
62
 
63
  const webhookUrl = useMemo(() => {
64
  if (typeof window === 'undefined') return '';
 
129
  }
130
  };
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  const loadThread = async (leadId) => {
133
  setThreadLoading(true);
134
  setThreadData(null);
 
159
  }
160
  };
161
 
162
+ const toggleRow = (id) => {
163
+ setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }));
164
+ };
165
+
166
+ const toggleAllPage = () => {
167
+ if (allPageSelected) {
168
+ setRowSelection((prev) => {
169
+ const next = { ...prev };
170
+ leads.forEach((l) => {
171
+ delete next[l.id];
172
+ });
173
+ return next;
174
+ });
175
+ } else {
176
+ setRowSelection((prev) => {
177
+ const next = { ...prev };
178
+ leads.forEach((l) => {
179
+ next[l.id] = true;
180
+ });
181
+ return next;
182
+ });
183
+ }
184
+ };
185
+
186
+ const runBulkAction = async (action) => {
187
+ if (selectedIds.length === 0) return;
188
+ setBulkBusy(action);
189
+ try {
190
+ if (action === 'move') {
191
+ const res = await fetch('/api/leads/bulk-move-to-contacts', {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json' },
194
+ body: JSON.stringify({ lead_ids: selectedIds }),
195
+ });
196
+ const data = await res.json().catch(() => ({}));
197
+ if (!res.ok) {
198
+ throw new Error(typeof data.detail === 'string' ? data.detail : 'Move failed');
199
+ }
200
+ if (data.errors?.length) {
201
+ alert(`Moved ${data.moved}. Some rows failed — check console.`);
202
+ console.warn(data.errors);
203
+ }
204
+ } else if (action === 'delete') {
205
+ if (!window.confirm(`Delete ${selectedIds.length} lead(s)? This cannot be undone.`)) {
206
+ return;
207
+ }
208
+ const res = await fetch('/api/leads/bulk-delete', {
209
+ method: 'POST',
210
+ headers: { 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({ lead_ids: selectedIds }),
212
+ });
213
+ const data = await res.json().catch(() => ({}));
214
+ if (!res.ok) {
215
+ throw new Error(typeof data.detail === 'string' ? data.detail : 'Delete failed');
216
+ }
217
+ } else if (action === 'deals') {
218
+ const res = await fetch('/api/deals/from-leads', {
219
+ method: 'POST',
220
+ headers: { 'Content-Type': 'application/json' },
221
+ body: JSON.stringify({ lead_ids: selectedIds }),
222
+ });
223
+ const data = await res.json().catch(() => ({}));
224
+ if (!res.ok) {
225
+ throw new Error(typeof data.detail === 'string' ? data.detail : 'Convert failed');
226
+ }
227
+ if (data.errors?.length) {
228
+ console.warn(data.errors);
229
+ }
230
+ navigate('/deals');
231
+ }
232
+ setRowSelection({});
233
+ setSelected(null);
234
+ await fetchLeads();
235
+ } catch (e) {
236
+ console.error(e);
237
+ alert(e.message || 'Action failed');
238
+ } finally {
239
+ setBulkBusy(null);
240
+ }
241
+ };
242
+
243
+ const bulkDisabled = selectedIds.length === 0 || bulkBusy;
244
+
245
  return (
246
  <AppShell
247
  title="Leads"
 
342
  </Button>
343
  </div>
344
  ) : (
345
+ <>
346
+ <div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-slate-100 bg-slate-50/90">
347
+ <span className="text-xs text-slate-500 mr-2">
348
+ {selectedIds.length} selected
349
+ </span>
350
+ <Button
351
+ type="button"
352
+ variant="outline"
353
+ size="icon"
354
+ className="h-9 w-9 shrink-0"
355
+ title="Move to contacts"
356
+ disabled={bulkDisabled}
357
+ onClick={() => runBulkAction('move')}
358
+ >
359
+ {bulkBusy === 'move' ? (
360
+ <Loader2 className="h-4 w-4 animate-spin" />
361
+ ) : (
362
+ <UserPlus className="h-4 w-4" />
363
+ )}
364
+ </Button>
365
+ <Button
366
+ type="button"
367
+ variant="outline"
368
+ size="icon"
369
+ className="h-9 w-9 shrink-0 text-red-600 border-red-200 hover:bg-red-50"
370
+ title="Delete"
371
+ disabled={bulkDisabled}
372
+ onClick={() => runBulkAction('delete')}
373
+ >
374
+ {bulkBusy === 'delete' ? (
375
+ <Loader2 className="h-4 w-4 animate-spin" />
376
+ ) : (
377
+ <Trash2 className="h-4 w-4" />
378
+ )}
379
+ </Button>
380
+ <Button
381
+ type="button"
382
+ variant="outline"
383
+ size="icon"
384
+ className="h-9 w-9 shrink-0 text-violet-700 border-violet-200 hover:bg-violet-50"
385
+ title="Convert as deals"
386
+ disabled={bulkDisabled}
387
+ onClick={() => runBulkAction('deals')}
388
+ >
389
+ {bulkBusy === 'deals' ? (
390
+ <Loader2 className="h-4 w-4 animate-spin" />
391
+ ) : (
392
+ <Handshake className="h-4 w-4" />
393
+ )}
394
+ </Button>
395
+ </div>
396
+ <table className="w-full text-sm">
397
+ <thead>
398
+ <tr className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
399
+ <th className="px-3 py-2 font-medium w-10">
400
+ <input
401
+ type="checkbox"
402
+ className="rounded border-slate-300"
403
+ checked={allPageSelected}
404
+ onChange={toggleAllPage}
405
+ aria-label="Select all on page"
406
+ />
407
+ </th>
408
+ <th className="px-3 py-2 font-medium">Lead</th>
409
+ <th className="px-3 py-2 font-medium">Status</th>
410
+ <th className="px-3 py-2 font-medium">Company</th>
411
+ <th className="px-3 py-2 font-medium">Title</th>
412
+ <th className="px-3 py-2 font-medium">Email</th>
413
+ <th className="px-3 py-2 font-medium">Last reply</th>
414
+ </tr>
415
+ </thead>
416
+ <tbody>
417
+ {leads.map((lead) => {
418
+ const meta = statusMeta(lead.crm_status);
419
+ const displayName =
420
+ [lead.first_name, lead.last_name].filter(Boolean).join(' ') ||
421
+ lead.email;
422
+ return (
423
+ <tr
424
+ key={lead.id}
425
+ className={cn(
426
+ 'border-b border-slate-100 hover:bg-violet-50/40',
427
+ selected?.id === lead.id && 'bg-violet-50/60'
428
+ )}
429
+ >
430
+ <td className="px-3 py-2">
431
+ <input
432
+ type="checkbox"
433
+ className="rounded border-slate-300"
434
+ checked={!!rowSelection[lead.id]}
435
+ onChange={() => toggleRow(lead.id)}
436
+ aria-label={`Select ${displayName}`}
437
+ />
438
+ </td>
439
+ <td className="px-3 py-2">
440
+ <button
441
+ type="button"
442
+ onClick={() => openDetail(lead)}
443
+ className="text-left text-violet-700 hover:underline font-medium"
444
  >
445
+ {displayName}
446
+ </button>
447
+ {lead.contact_id ? (
448
+ <span className="ml-2 text-xs text-emerald-600">
449
+ (in Contacts)
450
+ </span>
451
+ ) : null}
452
+ </td>
453
+ <td className="px-3 py-2">
454
+ <Select
455
+ value={statusValueForSelect(lead.crm_status)}
456
+ onValueChange={(v) => updateStatus(lead.id, v)}
457
+ >
458
+ <SelectTrigger
459
  className={cn(
460
+ 'h-9 w-[min(100%,200px)] border-slate-200',
461
+ 'shadow-none'
462
  )}
463
  >
464
+ <span
465
+ className={cn(
466
+ 'rounded-full px-2.5 py-0.5 text-xs font-medium',
467
+ meta.className
468
+ )}
 
 
 
 
469
  >
470
+ {meta.label}
471
+ </span>
472
+ </SelectTrigger>
473
+ <SelectContent className="min-w-[240px]">
474
+ {CRM_STATUSES.map((s) => (
475
+ <SelectItem
476
+ key={s.value}
477
+ value={s.value}
478
+ className="cursor-pointer"
479
  >
480
+ <span
481
+ className={cn(
482
+ 'rounded-full px-2.5 py-0.5 text-xs font-medium inline-block',
483
+ s.className
484
+ )}
485
+ >
486
+ {s.label}
487
+ </span>
488
+ </SelectItem>
489
+ ))}
490
+ </SelectContent>
491
+ </Select>
492
+ </td>
493
+ <td className="px-3 py-2 text-slate-700">
494
+ <span className="inline-flex items-center gap-1">
495
+ <Building2 className="h-3.5 w-3.5 text-slate-400" />
496
+ {lead.company_name || '—'}
497
+ </span>
498
+ </td>
499
+ <td className="px-3 py-2 text-slate-700">
500
+ <span className="inline-flex items-center gap-1">
501
+ <Briefcase className="h-3.5 w-3.5 text-slate-400" />
502
+ {lead.title || '—'}
503
+ </span>
504
+ </td>
505
+ <td className="px-3 py-2">
506
+ {lead.email ? (
507
+ <a
508
+ href={`mailto:${lead.email}`}
509
+ className="text-violet-600 hover:underline inline-flex items-center gap-1"
510
+ >
511
+ <Mail className="h-3.5 w-3.5" />
512
+ {lead.email}
513
+ </a>
514
+ ) : (
515
+ '—'
516
+ )}
517
+ </td>
518
+ <td className="px-3 py-2 text-slate-600 max-w-[200px] truncate" title={lead.last_reply_body}>
519
+ {lead.last_reply_body
520
+ ? lead.last_reply_body.slice(0, 80) +
521
+ (lead.last_reply_body.length > 80 ? '…' : '')
522
+ : '—'}
523
+ </td>
524
+ </tr>
525
+ );
526
+ })}
527
+ </tbody>
528
+ </table>
529
+ </>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  )}
531
  </div>
532
  )}