Seth commited on
Commit ·
8d2acd1
1
Parent(s): c50adfe
update
Browse files- backend/app/__pycache__/main.cpython-314.pyc +0 -0
- backend/app/__pycache__/models.cpython-314.pyc +0 -0
- backend/app/database.py +23 -1
- backend/app/main.py +327 -40
- backend/app/models.py +12 -0
- frontend/src/App.jsx +2 -0
- frontend/src/components/layout/AppHeader.jsx +1 -0
- frontend/src/components/layout/AppShell.jsx +2 -1
- frontend/src/pages/Deals.jsx +298 -0
- frontend/src/pages/Leads.jsx +283 -160
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|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 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 |
-
|
| 1356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1357 |
|
| 1358 |
-
email = (lead.email or "").strip()
|
| 1359 |
-
if not email:
|
| 1360 |
-
raise HTTPException(status_code=400, detail="Lead has no email")
|
| 1361 |
|
| 1362 |
-
|
| 1363 |
-
|
| 1364 |
-
|
| 1365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1366 |
)
|
| 1367 |
-
|
| 1368 |
-
|
| 1369 |
-
|
| 1370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1371 |
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
-
|
| 1375 |
-
|
| 1376 |
-
|
| 1377 |
-
|
| 1378 |
-
|
| 1379 |
-
|
| 1380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1381 |
}
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 1388 |
-
|
| 1389 |
-
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
-
db.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1394 |
db.commit()
|
| 1395 |
-
db.refresh(
|
| 1396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1397 |
db.commit()
|
| 1398 |
-
return {"
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 263 |
-
<
|
| 264 |
-
<
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
>
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
className={cn(
|
| 321 |
-
'
|
| 322 |
-
|
| 323 |
)}
|
| 324 |
>
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
<SelectItem
|
| 331 |
-
key={s.value}
|
| 332 |
-
value={s.value}
|
| 333 |
-
className="cursor-pointer"
|
| 334 |
>
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
>
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
<
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
{
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
<
|
| 379 |
-
|
| 380 |
-
{lead.
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 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 |
)}
|