Seth commited on
Commit Β·
230079f
1
Parent(s): efad20e
update
Browse files- backend/app/main.py +113 -2
- backend/app/models.py +30 -0
- frontend/src/components/workspace/CompanyDetailsEditor.jsx +142 -0
- frontend/src/pages/Contacts.jsx +27 -62
- frontend/src/pages/Deals.jsx +9 -9
- frontend/src/pages/Leads.jsx +8 -4
backend/app/main.py
CHANGED
|
@@ -297,6 +297,7 @@ def _crm_lead_to_dict(row: CrmLead) -> dict:
|
|
| 297 |
"contact_id": row.contact_id,
|
| 298 |
"created_at": row.created_at.isoformat() if row.created_at else None,
|
| 299 |
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
|
|
| 300 |
}
|
| 301 |
|
| 302 |
|
|
@@ -728,6 +729,90 @@ async def get_contact(contact_id: int, db: Session = Depends(get_db)):
|
|
| 728 |
}
|
| 729 |
|
| 730 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
def _sync_contact_raw_from_columns(contact: Contact) -> None:
|
| 732 |
rd = dict(contact.raw_data or {})
|
| 733 |
rd["First Name"] = contact.first_name or ""
|
|
@@ -767,6 +852,7 @@ async def patch_contact(contact_id: int, body: ContactPatchRequest, db: Session
|
|
| 767 |
if "title" in data:
|
| 768 |
contact.title = _safe_str(data["title"])
|
| 769 |
_sync_contact_raw_from_columns(contact)
|
|
|
|
| 770 |
db.commit()
|
| 771 |
db.refresh(contact)
|
| 772 |
return {
|
|
@@ -1805,6 +1891,14 @@ async def patch_lead(lead_id: int, body: CrmLeadPatchRequest, db: Session = Depe
|
|
| 1805 |
detail="Another lead already uses this email",
|
| 1806 |
)
|
| 1807 |
row.email = email
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1808 |
row.updated_at = datetime.utcnow()
|
| 1809 |
db.commit()
|
| 1810 |
db.refresh(row)
|
|
@@ -1964,7 +2058,7 @@ async def get_deal(deal_id: int, db: Session = Depends(get_db)):
|
|
| 1964 |
row = db.query(CrmDeal).filter(CrmDeal.id == deal_id).first()
|
| 1965 |
if not row:
|
| 1966 |
raise HTTPException(status_code=404, detail="Deal not found")
|
| 1967 |
-
return
|
| 1968 |
|
| 1969 |
|
| 1970 |
@app.patch("/api/deals/{deal_id}")
|
|
@@ -2002,10 +2096,27 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, db: Session = Depe
|
|
| 2002 |
if "last_interaction_at" in data:
|
| 2003 |
ts = _to_datetime(data["last_interaction_at"])
|
| 2004 |
row.last_interaction_at = ts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2005 |
row.updated_at = datetime.utcnow()
|
| 2006 |
db.commit()
|
| 2007 |
db.refresh(row)
|
| 2008 |
-
return
|
| 2009 |
|
| 2010 |
|
| 2011 |
@app.post("/api/deals/seed-demo")
|
|
|
|
| 297 |
"contact_id": row.contact_id,
|
| 298 |
"created_at": row.created_at.isoformat() if row.created_at else None,
|
| 299 |
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
| 300 |
+
"company_details": _lead_company_details_dict(row),
|
| 301 |
}
|
| 302 |
|
| 303 |
|
|
|
|
| 729 |
}
|
| 730 |
|
| 731 |
|
| 732 |
+
# Merged into Contact.raw_data["Company Name for Emails"], etc. (Apollo CSV keys)
|
| 733 |
+
COMPANY_DETAIL_PATCH_FIELDS = {
|
| 734 |
+
"company_name_for_emails": "Company Name for Emails",
|
| 735 |
+
"industry": "Industry",
|
| 736 |
+
"employees": "# Employees",
|
| 737 |
+
"annual_revenue": "Annual Revenue",
|
| 738 |
+
"last_raised_at": "Last Raised At",
|
| 739 |
+
"website": "Website",
|
| 740 |
+
"city": "City",
|
| 741 |
+
"state": "State",
|
| 742 |
+
"country_region": "Country",
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def _contact_company_details_dict(c: Contact) -> dict:
|
| 747 |
+
rd = c.raw_data or {}
|
| 748 |
+
return {
|
| 749 |
+
"Company Name": _safe_str(c.company or rd.get("Company Name")),
|
| 750 |
+
"Company Name for Emails": _safe_str(rd.get("Company Name for Emails")),
|
| 751 |
+
"Industry": _safe_str(rd.get("Industry")),
|
| 752 |
+
"# Employees": _safe_str(rd.get("# Employees")),
|
| 753 |
+
"Annual Revenue": _safe_str(rd.get("Annual Revenue")),
|
| 754 |
+
"Last Raised At": _safe_str(rd.get("Last Raised At")),
|
| 755 |
+
"Website": _safe_str(rd.get("Website")),
|
| 756 |
+
"City": _safe_str(rd.get("City")),
|
| 757 |
+
"State": _safe_str(rd.get("State")),
|
| 758 |
+
"Country": _safe_str(rd.get("Country")),
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
def _merge_contact_raw_company_details(contact: Contact, patch: dict) -> None:
|
| 763 |
+
rd = dict(contact.raw_data or {})
|
| 764 |
+
for py_key, raw_key in COMPANY_DETAIL_PATCH_FIELDS.items():
|
| 765 |
+
if py_key in patch:
|
| 766 |
+
rd[raw_key] = _safe_str(patch[py_key])
|
| 767 |
+
contact.raw_data = rd
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
def _lead_company_details_dict(row: CrmLead) -> dict:
|
| 771 |
+
rw = row.raw_webhook if isinstance(row.raw_webhook, dict) else {}
|
| 772 |
+
cd = rw.get("company_details") if isinstance(rw.get("company_details"), dict) else {}
|
| 773 |
+
return {
|
| 774 |
+
"Company Name": _safe_str(row.company_name or cd.get("Company Name")),
|
| 775 |
+
"Company Name for Emails": _safe_str(cd.get("Company Name for Emails")),
|
| 776 |
+
"Industry": _safe_str(cd.get("Industry")),
|
| 777 |
+
"# Employees": _safe_str(cd.get("# Employees")),
|
| 778 |
+
"Annual Revenue": _safe_str(cd.get("Annual Revenue")),
|
| 779 |
+
"Last Raised At": _safe_str(cd.get("Last Raised At")),
|
| 780 |
+
"Website": _safe_str(cd.get("Website")),
|
| 781 |
+
"City": _safe_str(cd.get("City")),
|
| 782 |
+
"State": _safe_str(cd.get("State")),
|
| 783 |
+
"Country": _safe_str(cd.get("Country")),
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
def _deal_fallback_company_details(d: CrmDeal) -> dict:
|
| 788 |
+
return {
|
| 789 |
+
"Company Name": _safe_str(d.account_name),
|
| 790 |
+
"Company Name for Emails": "",
|
| 791 |
+
"Industry": "",
|
| 792 |
+
"# Employees": "",
|
| 793 |
+
"Annual Revenue": "",
|
| 794 |
+
"Last Raised At": "",
|
| 795 |
+
"Website": "",
|
| 796 |
+
"City": "",
|
| 797 |
+
"State": "",
|
| 798 |
+
"Country": _safe_str(d.country),
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
|
| 802 |
+
def _enrich_deal_response(db: Session, row: CrmDeal) -> dict:
|
| 803 |
+
out = _deal_to_dict(row)
|
| 804 |
+
if row.contact_id:
|
| 805 |
+
c = db.query(Contact).filter(Contact.id == row.contact_id).first()
|
| 806 |
+
if c:
|
| 807 |
+
out["company_details"] = _contact_company_details_dict(c)
|
| 808 |
+
out["company_details_contact_id"] = c.id
|
| 809 |
+
else:
|
| 810 |
+
out["company_details"] = _deal_fallback_company_details(row)
|
| 811 |
+
else:
|
| 812 |
+
out["company_details"] = _deal_fallback_company_details(row)
|
| 813 |
+
return out
|
| 814 |
+
|
| 815 |
+
|
| 816 |
def _sync_contact_raw_from_columns(contact: Contact) -> None:
|
| 817 |
rd = dict(contact.raw_data or {})
|
| 818 |
rd["First Name"] = contact.first_name or ""
|
|
|
|
| 852 |
if "title" in data:
|
| 853 |
contact.title = _safe_str(data["title"])
|
| 854 |
_sync_contact_raw_from_columns(contact)
|
| 855 |
+
_merge_contact_raw_company_details(contact, data)
|
| 856 |
db.commit()
|
| 857 |
db.refresh(contact)
|
| 858 |
return {
|
|
|
|
| 1891 |
detail="Another lead already uses this email",
|
| 1892 |
)
|
| 1893 |
row.email = email
|
| 1894 |
+
rw = dict(row.raw_webhook) if isinstance(row.raw_webhook, dict) else {}
|
| 1895 |
+
cd = dict(rw.get("company_details") or {}) if isinstance(rw.get("company_details"), dict) else {}
|
| 1896 |
+
cd["Company Name"] = row.company_name or ""
|
| 1897 |
+
for py_key, raw_key in COMPANY_DETAIL_PATCH_FIELDS.items():
|
| 1898 |
+
if py_key in data:
|
| 1899 |
+
cd[raw_key] = _safe_str(data[py_key])
|
| 1900 |
+
rw["company_details"] = cd
|
| 1901 |
+
row.raw_webhook = rw
|
| 1902 |
row.updated_at = datetime.utcnow()
|
| 1903 |
db.commit()
|
| 1904 |
db.refresh(row)
|
|
|
|
| 2058 |
row = db.query(CrmDeal).filter(CrmDeal.id == deal_id).first()
|
| 2059 |
if not row:
|
| 2060 |
raise HTTPException(status_code=404, detail="Deal not found")
|
| 2061 |
+
return _enrich_deal_response(db, row)
|
| 2062 |
|
| 2063 |
|
| 2064 |
@app.patch("/api/deals/{deal_id}")
|
|
|
|
| 2096 |
if "last_interaction_at" in data:
|
| 2097 |
ts = _to_datetime(data["last_interaction_at"])
|
| 2098 |
row.last_interaction_at = ts
|
| 2099 |
+
|
| 2100 |
+
c = None
|
| 2101 |
+
if row.contact_id:
|
| 2102 |
+
c = db.query(Contact).filter(Contact.id == row.contact_id).first()
|
| 2103 |
+
if "account_name" in data and c:
|
| 2104 |
+
c.company = row.account_name
|
| 2105 |
+
if c:
|
| 2106 |
+
_sync_contact_raw_from_columns(c)
|
| 2107 |
+
if any(k in data for k in COMPANY_DETAIL_PATCH_FIELDS):
|
| 2108 |
+
_merge_contact_raw_company_details(c, data)
|
| 2109 |
+
if "country_region" in data:
|
| 2110 |
+
row.country = _safe_str(data["country_region"])
|
| 2111 |
+
if "country" in data and "country_region" not in data:
|
| 2112 |
+
rd = dict(c.raw_data or {})
|
| 2113 |
+
rd["Country"] = _safe_str(data["country"])
|
| 2114 |
+
c.raw_data = rd
|
| 2115 |
+
db.add(c)
|
| 2116 |
row.updated_at = datetime.utcnow()
|
| 2117 |
db.commit()
|
| 2118 |
db.refresh(row)
|
| 2119 |
+
return _enrich_deal_response(db, row)
|
| 2120 |
|
| 2121 |
|
| 2122 |
@app.post("/api/deals/seed-demo")
|
backend/app/models.py
CHANGED
|
@@ -42,6 +42,16 @@ class CrmLeadPatchRequest(BaseModel):
|
|
| 42 |
title: Optional[str] = None
|
| 43 |
last_reply_subject: Optional[str] = None
|
| 44 |
last_reply_body: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
class ContactCreateRequest(BaseModel):
|
|
@@ -58,6 +68,16 @@ class ContactPatchRequest(BaseModel):
|
|
| 58 |
email: Optional[str] = None
|
| 59 |
company: Optional[str] = None
|
| 60 |
title: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
class BulkLeadIdsRequest(BaseModel):
|
|
@@ -86,6 +106,16 @@ class CrmDealPatchRequest(BaseModel):
|
|
| 86 |
contact_display: Optional[str] = None
|
| 87 |
account_name: Optional[str] = None
|
| 88 |
last_interaction_at: Optional[str] = None # ISO date or datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
class SmartleadRunResponse(BaseModel):
|
|
|
|
| 42 |
title: Optional[str] = None
|
| 43 |
last_reply_subject: Optional[str] = None
|
| 44 |
last_reply_body: Optional[str] = None
|
| 45 |
+
# Stored under raw_webhook["company_details"] (same Apollo keys as Contacts raw_data)
|
| 46 |
+
company_name_for_emails: Optional[str] = None
|
| 47 |
+
industry: Optional[str] = None
|
| 48 |
+
employees: Optional[str] = None
|
| 49 |
+
annual_revenue: Optional[str] = None
|
| 50 |
+
last_raised_at: Optional[str] = None
|
| 51 |
+
website: Optional[str] = None
|
| 52 |
+
city: Optional[str] = None
|
| 53 |
+
state: Optional[str] = None
|
| 54 |
+
country_region: Optional[str] = None
|
| 55 |
|
| 56 |
|
| 57 |
class ContactCreateRequest(BaseModel):
|
|
|
|
| 68 |
email: Optional[str] = None
|
| 69 |
company: Optional[str] = None
|
| 70 |
title: Optional[str] = None
|
| 71 |
+
# Merged into raw_data (Apollo CSV-style keys)
|
| 72 |
+
company_name_for_emails: Optional[str] = None
|
| 73 |
+
industry: Optional[str] = None
|
| 74 |
+
employees: Optional[str] = None
|
| 75 |
+
annual_revenue: Optional[str] = None
|
| 76 |
+
last_raised_at: Optional[str] = None
|
| 77 |
+
website: Optional[str] = None
|
| 78 |
+
city: Optional[str] = None
|
| 79 |
+
state: Optional[str] = None
|
| 80 |
+
country_region: Optional[str] = None # raw_data["Country"]
|
| 81 |
|
| 82 |
|
| 83 |
class BulkLeadIdsRequest(BaseModel):
|
|
|
|
| 106 |
contact_display: Optional[str] = None
|
| 107 |
account_name: Optional[str] = None
|
| 108 |
last_interaction_at: Optional[str] = None # ISO date or datetime
|
| 109 |
+
# When deal has contact_id, merged into that contact's raw_data (Apollo keys)
|
| 110 |
+
company_name_for_emails: Optional[str] = None
|
| 111 |
+
industry: Optional[str] = None
|
| 112 |
+
employees: Optional[str] = None
|
| 113 |
+
annual_revenue: Optional[str] = None
|
| 114 |
+
last_raised_at: Optional[str] = None
|
| 115 |
+
website: Optional[str] = None
|
| 116 |
+
city: Optional[str] = None
|
| 117 |
+
state: Optional[str] = None
|
| 118 |
+
country_region: Optional[str] = None
|
| 119 |
|
| 120 |
|
| 121 |
class SmartleadRunResponse(BaseModel):
|
frontend/src/components/workspace/CompanyDetailsEditor.jsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { Loader2 } from 'lucide-react';
|
| 3 |
+
import { Button } from '@/components/ui/button';
|
| 4 |
+
import { Input } from '@/components/ui/input';
|
| 5 |
+
import { cn } from '@/lib/utils';
|
| 6 |
+
|
| 7 |
+
function formFromDetails(cd, fallbackCompany = '') {
|
| 8 |
+
const d = cd || {};
|
| 9 |
+
return {
|
| 10 |
+
companyName: d['Company Name'] || fallbackCompany || '',
|
| 11 |
+
companyNameForEmails: d['Company Name for Emails'] || '',
|
| 12 |
+
industry: d['Industry'] || '',
|
| 13 |
+
employees: d['# Employees'] || '',
|
| 14 |
+
annualRevenue: d['Annual Revenue'] || '',
|
| 15 |
+
lastRaisedAt: d['Last Raised At'] || '',
|
| 16 |
+
website: d['Website'] || '',
|
| 17 |
+
city: d['City'] || '',
|
| 18 |
+
state: d['State'] || '',
|
| 19 |
+
country: d['Country'] || '',
|
| 20 |
+
};
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function buildPatch(variant, form, dealLinkedContact) {
|
| 24 |
+
const detail = {
|
| 25 |
+
company_name_for_emails: form.companyNameForEmails,
|
| 26 |
+
industry: form.industry,
|
| 27 |
+
employees: form.employees,
|
| 28 |
+
annual_revenue: form.annualRevenue,
|
| 29 |
+
last_raised_at: form.lastRaisedAt,
|
| 30 |
+
website: form.website,
|
| 31 |
+
city: form.city,
|
| 32 |
+
state: form.state,
|
| 33 |
+
country_region: form.country,
|
| 34 |
+
};
|
| 35 |
+
if (variant === 'contact') {
|
| 36 |
+
return { company: form.companyName, ...detail };
|
| 37 |
+
}
|
| 38 |
+
if (variant === 'lead') {
|
| 39 |
+
return { company_name: form.companyName, ...detail };
|
| 40 |
+
}
|
| 41 |
+
if (variant === 'deal') {
|
| 42 |
+
if (dealLinkedContact) {
|
| 43 |
+
return { account_name: form.companyName, ...detail };
|
| 44 |
+
}
|
| 45 |
+
return {
|
| 46 |
+
account_name: form.companyName,
|
| 47 |
+
country: form.country,
|
| 48 |
+
};
|
| 49 |
+
}
|
| 50 |
+
return detail;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* Editable βCompany detailsβ card (two-column grid) + Save, for Contacts / Leads / Deals slide-overs.
|
| 55 |
+
* `companyDetails` uses Apollo-style keys from the API (`company_details` or contact `raw_data`).
|
| 56 |
+
*/
|
| 57 |
+
export default function CompanyDetailsEditor({
|
| 58 |
+
variant,
|
| 59 |
+
companyDetails,
|
| 60 |
+
fallbackCompanyName = '',
|
| 61 |
+
dealLinkedContact = false,
|
| 62 |
+
onSave,
|
| 63 |
+
className,
|
| 64 |
+
}) {
|
| 65 |
+
const [form, setForm] = useState(() => formFromDetails(companyDetails, fallbackCompanyName));
|
| 66 |
+
const [saving, setSaving] = useState(false);
|
| 67 |
+
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
setForm(formFromDetails(companyDetails, fallbackCompanyName));
|
| 70 |
+
}, [companyDetails, fallbackCompanyName]);
|
| 71 |
+
|
| 72 |
+
const dealLimited = variant === 'deal' && !dealLinkedContact;
|
| 73 |
+
const isInputDisabled = (key) => dealLimited && key !== 'companyName' && key !== 'country';
|
| 74 |
+
|
| 75 |
+
const setField = (key, value) => setForm((f) => ({ ...f, [key]: value }));
|
| 76 |
+
|
| 77 |
+
const handleSave = async () => {
|
| 78 |
+
setSaving(true);
|
| 79 |
+
try {
|
| 80 |
+
await onSave(buildPatch(variant, form, dealLinkedContact));
|
| 81 |
+
} finally {
|
| 82 |
+
setSaving(false);
|
| 83 |
+
}
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const row = (label, key, opts = {}) => (
|
| 87 |
+
<div className={opts.span === 2 ? 'sm:col-span-2' : ''}>
|
| 88 |
+
<label className="block text-xs font-medium text-slate-500 mb-1">{label}</label>
|
| 89 |
+
<Input
|
| 90 |
+
value={form[key]}
|
| 91 |
+
onChange={(e) => setField(key, e.target.value)}
|
| 92 |
+
disabled={isInputDisabled(key)}
|
| 93 |
+
className="text-sm bg-white"
|
| 94 |
+
placeholder={opts.placeholder}
|
| 95 |
+
/>
|
| 96 |
+
</div>
|
| 97 |
+
);
|
| 98 |
+
|
| 99 |
+
return (
|
| 100 |
+
<div
|
| 101 |
+
className={cn(
|
| 102 |
+
'rounded-xl border border-slate-200 bg-slate-50/50 p-4 mb-6 flex flex-col gap-4',
|
| 103 |
+
className
|
| 104 |
+
)}
|
| 105 |
+
>
|
| 106 |
+
<h4 className="text-sm font-semibold text-slate-700">Company details</h4>
|
| 107 |
+
|
| 108 |
+
{dealLimited ? (
|
| 109 |
+
<p className="text-xs text-slate-500">
|
| 110 |
+
Link this deal to a contact to edit full company attributes. Account name and country can be edited
|
| 111 |
+
below.
|
| 112 |
+
</p>
|
| 113 |
+
) : null}
|
| 114 |
+
|
| 115 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
| 116 |
+
{row('Company Name', 'companyName')}
|
| 117 |
+
{row('Company Name for Emails', 'companyNameForEmails')}
|
| 118 |
+
{row('Industry', 'industry', { span: 2 })}
|
| 119 |
+
{row('Employees', 'employees', { detail: true })}
|
| 120 |
+
{row('Annual Revenue', 'annualRevenue', { detail: true })}
|
| 121 |
+
{row('Last Raised At', 'lastRaisedAt', { detail: true })}
|
| 122 |
+
{row('Website', 'website', { detail: true, placeholder: 'https://' })}
|
| 123 |
+
{row('City', 'city', { detail: true })}
|
| 124 |
+
{row('State', 'state', { detail: true })}
|
| 125 |
+
{row('Country', 'country', { detail: true })}
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
<div className="pt-2 border-t border-slate-200/80">
|
| 129 |
+
<Button type="button" className="w-full" onClick={handleSave} disabled={saving}>
|
| 130 |
+
{saving ? (
|
| 131 |
+
<>
|
| 132 |
+
<Loader2 className="h-4 w-4 animate-spin mr-2" aria-hidden />
|
| 133 |
+
Savingβ¦
|
| 134 |
+
</>
|
| 135 |
+
) : (
|
| 136 |
+
'Save'
|
| 137 |
+
)}
|
| 138 |
+
</Button>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
);
|
| 142 |
+
}
|
frontend/src/pages/Contacts.jsx
CHANGED
|
@@ -25,6 +25,7 @@ import { Button } from '@/components/ui/button';
|
|
| 25 |
import AppShell from '@/components/layout/AppShell';
|
| 26 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 27 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
|
|
|
| 28 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 29 |
import { cn } from '@/lib/utils';
|
| 30 |
|
|
@@ -72,6 +73,23 @@ export default function Contacts() {
|
|
| 72 |
[rowSelection]
|
| 73 |
);
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
const allPageSelected = contacts.length > 0 && contacts.every((c) => rowSelection[c.id]);
|
| 76 |
|
| 77 |
useEffect(() => {
|
|
@@ -993,68 +1011,15 @@ export default function Contacts() {
|
|
| 993 |
</Badge>
|
| 994 |
</div>
|
| 995 |
|
| 996 |
-
{
|
| 997 |
-
<
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
'N/A'}
|
| 1006 |
-
</span>
|
| 1007 |
-
</div>
|
| 1008 |
-
<div>
|
| 1009 |
-
<span className="text-slate-500">Company Name for Emails:</span>{' '}
|
| 1010 |
-
<span className="text-slate-800">
|
| 1011 |
-
{selectedContactDetails.raw_data['Company Name for Emails'] || 'N/A'}
|
| 1012 |
-
</span>
|
| 1013 |
-
</div>
|
| 1014 |
-
<div>
|
| 1015 |
-
<span className="text-slate-500">Industry:</span>{' '}
|
| 1016 |
-
<span className="text-slate-800">
|
| 1017 |
-
{selectedContactDetails.raw_data['Industry'] || 'N/A'}
|
| 1018 |
-
</span>
|
| 1019 |
-
</div>
|
| 1020 |
-
<div>
|
| 1021 |
-
<span className="text-slate-500">Employees:</span>{' '}
|
| 1022 |
-
<span className="text-slate-800">
|
| 1023 |
-
{selectedContactDetails.raw_data['# Employees'] || 'N/A'}
|
| 1024 |
-
</span>
|
| 1025 |
-
</div>
|
| 1026 |
-
<div>
|
| 1027 |
-
<span className="text-slate-500">Annual Revenue:</span>{' '}
|
| 1028 |
-
<span className="text-slate-800">
|
| 1029 |
-
{selectedContactDetails.raw_data['Annual Revenue'] || 'N/A'}
|
| 1030 |
-
</span>
|
| 1031 |
-
</div>
|
| 1032 |
-
<div>
|
| 1033 |
-
<span className="text-slate-500">Last Raised At:</span>{' '}
|
| 1034 |
-
<span className="text-slate-800">
|
| 1035 |
-
{selectedContactDetails.raw_data['Last Raised At'] || 'N/A'}
|
| 1036 |
-
</span>
|
| 1037 |
-
</div>
|
| 1038 |
-
<div>
|
| 1039 |
-
<span className="text-slate-500">Website:</span>{' '}
|
| 1040 |
-
<span className="text-slate-800">
|
| 1041 |
-
{selectedContactDetails.raw_data['Website'] || 'N/A'}
|
| 1042 |
-
</span>
|
| 1043 |
-
</div>
|
| 1044 |
-
<div>
|
| 1045 |
-
<span className="text-slate-500">Location:</span>{' '}
|
| 1046 |
-
<span className="text-slate-800">
|
| 1047 |
-
{[
|
| 1048 |
-
selectedContactDetails.raw_data['City'],
|
| 1049 |
-
selectedContactDetails.raw_data['State'],
|
| 1050 |
-
selectedContactDetails.raw_data['Country'],
|
| 1051 |
-
]
|
| 1052 |
-
.filter(Boolean)
|
| 1053 |
-
.join(', ') || 'N/A'}
|
| 1054 |
-
</span>
|
| 1055 |
-
</div>
|
| 1056 |
-
</div>
|
| 1057 |
-
</div>
|
| 1058 |
)}
|
| 1059 |
|
| 1060 |
<h4 className="text-sm font-semibold text-slate-800 mb-2 flex items-center gap-2">
|
|
|
|
| 25 |
import AppShell from '@/components/layout/AppShell';
|
| 26 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 27 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 28 |
+
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 29 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 30 |
import { cn } from '@/lib/utils';
|
| 31 |
|
|
|
|
| 73 |
[rowSelection]
|
| 74 |
);
|
| 75 |
|
| 76 |
+
const contactCompanyDetailsForEditor = useMemo(() => {
|
| 77 |
+
if (!selectedContactDetails) return null;
|
| 78 |
+
const rd = selectedContactDetails.raw_data || {};
|
| 79 |
+
return {
|
| 80 |
+
'Company Name': rd['Company Name'] ?? selectedContactDetails.company ?? '',
|
| 81 |
+
'Company Name for Emails': rd['Company Name for Emails'] ?? '',
|
| 82 |
+
Industry: rd['Industry'] ?? '',
|
| 83 |
+
'# Employees': rd['# Employees'] ?? '',
|
| 84 |
+
'Annual Revenue': rd['Annual Revenue'] ?? '',
|
| 85 |
+
'Last Raised At': rd['Last Raised At'] ?? '',
|
| 86 |
+
Website: rd['Website'] ?? '',
|
| 87 |
+
City: rd['City'] ?? '',
|
| 88 |
+
State: rd['State'] ?? '',
|
| 89 |
+
Country: rd['Country'] ?? '',
|
| 90 |
+
};
|
| 91 |
+
}, [selectedContactDetails]);
|
| 92 |
+
|
| 93 |
const allPageSelected = contacts.length > 0 && contacts.every((c) => rowSelection[c.id]);
|
| 94 |
|
| 95 |
useEffect(() => {
|
|
|
|
| 1011 |
</Badge>
|
| 1012 |
</div>
|
| 1013 |
|
| 1014 |
+
{contactCompanyDetailsForEditor && selectedContact && (
|
| 1015 |
+
<CompanyDetailsEditor
|
| 1016 |
+
variant="contact"
|
| 1017 |
+
companyDetails={contactCompanyDetailsForEditor}
|
| 1018 |
+
fallbackCompanyName={
|
| 1019 |
+
selectedContactDetails?.company || selectedContact.company || ''
|
| 1020 |
+
}
|
| 1021 |
+
onSave={(patch) => patchContact(selectedContact.id, patch)}
|
| 1022 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1023 |
)}
|
| 1024 |
|
| 1025 |
<h4 className="text-sm font-semibold text-slate-800 mb-2 flex items-center gap-2">
|
frontend/src/pages/Deals.jsx
CHANGED
|
@@ -6,6 +6,7 @@ import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/u
|
|
| 6 |
import AppShell from '@/components/layout/AppShell';
|
| 7 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 8 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
|
|
|
| 9 |
import { EditableCell, EditableDateCell } from '@/components/workspace/EditableCell';
|
| 10 |
import { SearchableCountryPicker } from '@/components/workspace/SearchableCountryPicker';
|
| 11 |
import { cn } from '@/lib/utils';
|
|
@@ -1005,10 +1006,17 @@ export default function Deals() {
|
|
| 1005 |
? `Stage: ${stageMeta(dealDetail.stage).label} Β· Forecast ${fmtMoney(dealDetail.forecast_value)}`
|
| 1006 |
: ''
|
| 1007 |
}
|
| 1008 |
-
widthClassName="max-w-
|
| 1009 |
>
|
| 1010 |
{dealDetail && (
|
| 1011 |
<div className="space-y-5 text-sm">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1012 |
<dl className="space-y-3">
|
| 1013 |
<div>
|
| 1014 |
<dt className="text-slate-500">Deal value</dt>
|
|
@@ -1026,14 +1034,6 @@ export default function Deals() {
|
|
| 1026 |
<dt className="text-slate-500">Contact</dt>
|
| 1027 |
<dd>{dealDetail.contact_display || 'β'}</dd>
|
| 1028 |
</div>
|
| 1029 |
-
<div>
|
| 1030 |
-
<dt className="text-slate-500">Account</dt>
|
| 1031 |
-
<dd>{dealDetail.account_name || 'β'}</dd>
|
| 1032 |
-
</div>
|
| 1033 |
-
<div>
|
| 1034 |
-
<dt className="text-slate-500">Country</dt>
|
| 1035 |
-
<dd>{dealDetail.country || 'β'}</dd>
|
| 1036 |
-
</div>
|
| 1037 |
<div>
|
| 1038 |
<dt className="text-slate-500">Last interaction</dt>
|
| 1039 |
<dd>{fmtDate(dealDetail.last_interaction_at)}</dd>
|
|
|
|
| 6 |
import AppShell from '@/components/layout/AppShell';
|
| 7 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 8 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 9 |
+
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 10 |
import { EditableCell, EditableDateCell } from '@/components/workspace/EditableCell';
|
| 11 |
import { SearchableCountryPicker } from '@/components/workspace/SearchableCountryPicker';
|
| 12 |
import { cn } from '@/lib/utils';
|
|
|
|
| 1006 |
? `Stage: ${stageMeta(dealDetail.stage).label} Β· Forecast ${fmtMoney(dealDetail.forecast_value)}`
|
| 1007 |
: ''
|
| 1008 |
}
|
| 1009 |
+
widthClassName="max-w-xl"
|
| 1010 |
>
|
| 1011 |
{dealDetail && (
|
| 1012 |
<div className="space-y-5 text-sm">
|
| 1013 |
+
<CompanyDetailsEditor
|
| 1014 |
+
variant="deal"
|
| 1015 |
+
dealLinkedContact={!!dealDetail.contact_id}
|
| 1016 |
+
companyDetails={dealDetail.company_details}
|
| 1017 |
+
fallbackCompanyName={dealDetail.account_name || ''}
|
| 1018 |
+
onSave={(patch) => patchDeal(dealDetail.id, patch)}
|
| 1019 |
+
/>
|
| 1020 |
<dl className="space-y-3">
|
| 1021 |
<div>
|
| 1022 |
<dt className="text-slate-500">Deal value</dt>
|
|
|
|
| 1034 |
<dt className="text-slate-500">Contact</dt>
|
| 1035 |
<dd>{dealDetail.contact_display || 'β'}</dd>
|
| 1036 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1037 |
<div>
|
| 1038 |
<dt className="text-slate-500">Last interaction</dt>
|
| 1039 |
<dd>{fmtDate(dealDetail.last_interaction_at)}</dd>
|
frontend/src/pages/Leads.jsx
CHANGED
|
@@ -17,6 +17,7 @@ import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/u
|
|
| 17 |
import AppShell from '@/components/layout/AppShell';
|
| 18 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 19 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
|
|
|
| 20 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 21 |
import { cn } from '@/lib/utils';
|
| 22 |
|
|
@@ -672,9 +673,16 @@ export default function Leads() {
|
|
| 672 |
? `Campaign: ${selected.campaign_name || selected.campaign_id || 'β'}`
|
| 673 |
: ''
|
| 674 |
}
|
|
|
|
| 675 |
>
|
| 676 |
{selected && (
|
| 677 |
<>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
<dl className="space-y-3 text-sm">
|
| 679 |
<div>
|
| 680 |
<dt className="text-slate-500">Name</dt>
|
|
@@ -686,10 +694,6 @@ export default function Leads() {
|
|
| 686 |
<dt className="text-slate-500">Email</dt>
|
| 687 |
<dd>{selected.email || 'β'}</dd>
|
| 688 |
</div>
|
| 689 |
-
<div>
|
| 690 |
-
<dt className="text-slate-500">Company</dt>
|
| 691 |
-
<dd>{selected.company_name || 'β'}</dd>
|
| 692 |
-
</div>
|
| 693 |
<div>
|
| 694 |
<dt className="text-slate-500">Title</dt>
|
| 695 |
<dd>{selected.title || 'β'}</dd>
|
|
|
|
| 17 |
import AppShell from '@/components/layout/AppShell';
|
| 18 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 19 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 20 |
+
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 21 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 22 |
import { cn } from '@/lib/utils';
|
| 23 |
|
|
|
|
| 673 |
? `Campaign: ${selected.campaign_name || selected.campaign_id || 'β'}`
|
| 674 |
: ''
|
| 675 |
}
|
| 676 |
+
widthClassName="max-w-xl"
|
| 677 |
>
|
| 678 |
{selected && (
|
| 679 |
<>
|
| 680 |
+
<CompanyDetailsEditor
|
| 681 |
+
variant="lead"
|
| 682 |
+
companyDetails={selected.company_details}
|
| 683 |
+
fallbackCompanyName={selected.company_name || ''}
|
| 684 |
+
onSave={(patch) => patchLead(selected.id, patch)}
|
| 685 |
+
/>
|
| 686 |
<dl className="space-y-3 text-sm">
|
| 687 |
<div>
|
| 688 |
<dt className="text-slate-500">Name</dt>
|
|
|
|
| 694 |
<dt className="text-slate-500">Email</dt>
|
| 695 |
<dd>{selected.email || 'β'}</dd>
|
| 696 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
<div>
|
| 698 |
<dt className="text-slate-500">Title</dt>
|
| 699 |
<dd>{selected.title || 'β'}</dd>
|