Seth commited on
Commit ·
2a3a36f
1
Parent(s): 230079f
update
Browse files- backend/app/main.py +65 -0
- backend/app/models.py +1 -0
- frontend/src/components/workspace/CompanyDetailsEditor.jsx +32 -8
- frontend/src/components/workspace/ContactIdentityEditor.jsx +105 -0
- frontend/src/components/workspace/DealLinkSearch.jsx +181 -0
- frontend/src/pages/Contacts.jsx +29 -53
- frontend/src/pages/Deals.jsx +104 -15
- frontend/src/pages/Leads.jsx +52 -31
backend/app/main.py
CHANGED
|
@@ -709,6 +709,43 @@ async def bulk_convert_contacts_to_leads(body: BulkContactIdsRequest, db: Sessio
|
|
| 709 |
return {"converted": converted, "errors": errors}
|
| 710 |
|
| 711 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 712 |
@app.get("/api/contacts/{contact_id}")
|
| 713 |
async def get_contact(contact_id: int, db: Session = Depends(get_db)):
|
| 714 |
contact = db.query(Contact).filter(Contact.id == contact_id).first()
|
|
@@ -799,13 +836,28 @@ def _deal_fallback_company_details(d: CrmDeal) -> dict:
|
|
| 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:
|
|
@@ -1844,6 +1896,7 @@ async def get_lead(lead_id: int, db: Session = Depends(get_db)):
|
|
| 1844 |
"company": c.company,
|
| 1845 |
"title": c.title,
|
| 1846 |
}
|
|
|
|
| 1847 |
return d
|
| 1848 |
|
| 1849 |
|
|
@@ -2069,6 +2122,18 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, db: Session = Depe
|
|
| 2069 |
data = body.model_dump(exclude_unset=True)
|
| 2070 |
if not data:
|
| 2071 |
raise HTTPException(status_code=400, detail="No fields to update")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2072 |
if "name" in data:
|
| 2073 |
row.name = _safe_str(data["name"])
|
| 2074 |
if "owner_initials" in data:
|
|
|
|
| 709 |
return {"converted": converted, "errors": errors}
|
| 710 |
|
| 711 |
|
| 712 |
+
@app.get("/api/company-names")
|
| 713 |
+
async def search_company_names(
|
| 714 |
+
q: str = Query("", description="Substring match on Contact.company"),
|
| 715 |
+
limit: int = Query(25, ge=1, le=100),
|
| 716 |
+
db: Session = Depends(get_db),
|
| 717 |
+
):
|
| 718 |
+
"""Distinct company strings from contacts for deal/account linking."""
|
| 719 |
+
raw_q = _safe_str(q)
|
| 720 |
+
pattern = f"%{raw_q}%" if raw_q else "%"
|
| 721 |
+
rows = (
|
| 722 |
+
db.query(Contact.company)
|
| 723 |
+
.filter(
|
| 724 |
+
Contact.company.isnot(None),
|
| 725 |
+
Contact.company != "",
|
| 726 |
+
Contact.company.ilike(pattern),
|
| 727 |
+
)
|
| 728 |
+
.distinct()
|
| 729 |
+
.order_by(Contact.company.asc())
|
| 730 |
+
.limit(limit * 4)
|
| 731 |
+
.all()
|
| 732 |
+
)
|
| 733 |
+
names: List[str] = []
|
| 734 |
+
seen = set()
|
| 735 |
+
for (co,) in rows:
|
| 736 |
+
s = _safe_str(co)
|
| 737 |
+
if not s:
|
| 738 |
+
continue
|
| 739 |
+
lk = s.lower()
|
| 740 |
+
if lk in seen:
|
| 741 |
+
continue
|
| 742 |
+
seen.add(lk)
|
| 743 |
+
names.append(s)
|
| 744 |
+
if len(names) >= limit:
|
| 745 |
+
break
|
| 746 |
+
return {"names": names}
|
| 747 |
+
|
| 748 |
+
|
| 749 |
@app.get("/api/contacts/{contact_id}")
|
| 750 |
async def get_contact(contact_id: int, db: Session = Depends(get_db)):
|
| 751 |
contact = db.query(Contact).filter(Contact.id == contact_id).first()
|
|
|
|
| 836 |
}
|
| 837 |
|
| 838 |
|
| 839 |
+
def _format_contact_display(contact: Contact) -> str:
|
| 840 |
+
fn = _safe_str(contact.first_name)
|
| 841 |
+
ln = _safe_str(contact.last_name)
|
| 842 |
+
person = " ".join(filter(None, [fn, ln])).strip()
|
| 843 |
+
return person or _safe_str(contact.email)
|
| 844 |
+
|
| 845 |
+
|
| 846 |
def _enrich_deal_response(db: Session, row: CrmDeal) -> dict:
|
| 847 |
out = _deal_to_dict(row)
|
| 848 |
+
out["linked_contact"] = None
|
| 849 |
if row.contact_id:
|
| 850 |
c = db.query(Contact).filter(Contact.id == row.contact_id).first()
|
| 851 |
if c:
|
| 852 |
out["company_details"] = _contact_company_details_dict(c)
|
| 853 |
out["company_details_contact_id"] = c.id
|
| 854 |
+
out["linked_contact"] = {
|
| 855 |
+
"id": c.id,
|
| 856 |
+
"first_name": c.first_name or "",
|
| 857 |
+
"last_name": c.last_name or "",
|
| 858 |
+
"email": c.email or "",
|
| 859 |
+
"title": c.title or "",
|
| 860 |
+
}
|
| 861 |
else:
|
| 862 |
out["company_details"] = _deal_fallback_company_details(row)
|
| 863 |
else:
|
|
|
|
| 1896 |
"company": c.company,
|
| 1897 |
"title": c.title,
|
| 1898 |
}
|
| 1899 |
+
d["company_details"] = _contact_company_details_dict(c)
|
| 1900 |
return d
|
| 1901 |
|
| 1902 |
|
|
|
|
| 2122 |
data = body.model_dump(exclude_unset=True)
|
| 2123 |
if not data:
|
| 2124 |
raise HTTPException(status_code=400, detail="No fields to update")
|
| 2125 |
+
if "contact_id" in data:
|
| 2126 |
+
cid = data["contact_id"]
|
| 2127 |
+
if cid is None:
|
| 2128 |
+
row.contact_id = None
|
| 2129 |
+
else:
|
| 2130 |
+
contact = db.query(Contact).filter(Contact.id == int(cid)).first()
|
| 2131 |
+
if not contact:
|
| 2132 |
+
raise HTTPException(status_code=404, detail="Contact not found")
|
| 2133 |
+
row.contact_id = contact.id
|
| 2134 |
+
row.contact_display = _format_contact_display(contact)
|
| 2135 |
+
if "account_name" not in data and _safe_str(contact.company):
|
| 2136 |
+
row.account_name = _safe_str(contact.company)
|
| 2137 |
if "name" in data:
|
| 2138 |
row.name = _safe_str(data["name"])
|
| 2139 |
if "owner_initials" in data:
|
backend/app/models.py
CHANGED
|
@@ -106,6 +106,7 @@ class CrmDealPatchRequest(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
|
|
|
|
| 106 |
contact_display: Optional[str] = None
|
| 107 |
account_name: Optional[str] = None
|
| 108 |
last_interaction_at: Optional[str] = None # ISO date or datetime
|
| 109 |
+
contact_id: Optional[int] = None # set CRM contact on deal; JSON null clears link
|
| 110 |
# When deal has contact_id, merged into that contact's raw_data (Apollo keys)
|
| 111 |
company_name_for_emails: Optional[str] = None
|
| 112 |
industry: Optional[str] = None
|
frontend/src/components/workspace/CompanyDetailsEditor.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 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';
|
|
@@ -61,6 +61,10 @@ export default function CompanyDetailsEditor({
|
|
| 61 |
dealLinkedContact = false,
|
| 62 |
onSave,
|
| 63 |
className,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}) {
|
| 65 |
const [form, setForm] = useState(() => formFromDetails(companyDetails, fallbackCompanyName));
|
| 66 |
const [saving, setSaving] = useState(false);
|
|
@@ -97,13 +101,33 @@ export default function CompanyDetailsEditor({
|
|
| 97 |
);
|
| 98 |
|
| 99 |
return (
|
| 100 |
-
<div
|
| 101 |
-
className=
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
{dealLimited ? (
|
| 109 |
<p className="text-xs text-slate-500">
|
|
|
|
| 1 |
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { Loader2, Sparkles } from 'lucide-react';
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
import { Input } from '@/components/ui/input';
|
| 5 |
import { cn } from '@/lib/utils';
|
|
|
|
| 61 |
dealLinkedContact = false,
|
| 62 |
onSave,
|
| 63 |
className,
|
| 64 |
+
showFetch = false,
|
| 65 |
+
onFetch,
|
| 66 |
+
fetchLoading = false,
|
| 67 |
+
fetchError = '',
|
| 68 |
}) {
|
| 69 |
const [form, setForm] = useState(() => formFromDetails(companyDetails, fallbackCompanyName));
|
| 70 |
const [saving, setSaving] = useState(false);
|
|
|
|
| 101 |
);
|
| 102 |
|
| 103 |
return (
|
| 104 |
+
<div className={cn('rounded-xl border border-slate-200 bg-slate-50/50 p-4 flex flex-col gap-4 mb-6', className)}>
|
| 105 |
+
<div className="flex flex-wrap items-start justify-between gap-2">
|
| 106 |
+
<h4 className="text-sm font-semibold text-slate-700">Company details</h4>
|
| 107 |
+
{showFetch && onFetch ? (
|
| 108 |
+
<Button
|
| 109 |
+
type="button"
|
| 110 |
+
variant="outline"
|
| 111 |
+
size="sm"
|
| 112 |
+
className="gap-1.5 shrink-0 text-violet-700 border-violet-200 hover:bg-violet-50"
|
| 113 |
+
onClick={() => onFetch()}
|
| 114 |
+
disabled={fetchLoading}
|
| 115 |
+
>
|
| 116 |
+
{fetchLoading ? (
|
| 117 |
+
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" aria-hidden />
|
| 118 |
+
) : (
|
| 119 |
+
<Sparkles className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
| 120 |
+
)}
|
| 121 |
+
Fetch
|
| 122 |
+
</Button>
|
| 123 |
+
) : null}
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{fetchError ? (
|
| 127 |
+
<p className="text-xs text-red-600" role="alert">
|
| 128 |
+
{fetchError}
|
| 129 |
+
</p>
|
| 130 |
+
) : null}
|
| 131 |
|
| 132 |
{dealLimited ? (
|
| 133 |
<p className="text-xs text-slate-500">
|
frontend/src/components/workspace/ContactIdentityEditor.jsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
/**
|
| 8 |
+
* Editable person fields (first / last name, email, title) + Save — slide-over top section.
|
| 9 |
+
*/
|
| 10 |
+
export default function ContactIdentityEditor({
|
| 11 |
+
firstName = '',
|
| 12 |
+
lastName = '',
|
| 13 |
+
email = '',
|
| 14 |
+
title = '',
|
| 15 |
+
onSave,
|
| 16 |
+
disabled = false,
|
| 17 |
+
className,
|
| 18 |
+
heading = 'Contact details',
|
| 19 |
+
}) {
|
| 20 |
+
const [form, setForm] = useState({
|
| 21 |
+
firstName,
|
| 22 |
+
lastName,
|
| 23 |
+
email,
|
| 24 |
+
title,
|
| 25 |
+
});
|
| 26 |
+
const [saving, setSaving] = useState(false);
|
| 27 |
+
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
setForm({ firstName, lastName, email, title });
|
| 30 |
+
}, [firstName, lastName, email, title]);
|
| 31 |
+
|
| 32 |
+
const setField = (key, value) => setForm((f) => ({ ...f, [key]: value }));
|
| 33 |
+
|
| 34 |
+
const handleSave = async () => {
|
| 35 |
+
if (!onSave || disabled) return;
|
| 36 |
+
setSaving(true);
|
| 37 |
+
try {
|
| 38 |
+
await onSave({
|
| 39 |
+
first_name: form.firstName,
|
| 40 |
+
last_name: form.lastName,
|
| 41 |
+
email: form.email,
|
| 42 |
+
title: form.title,
|
| 43 |
+
});
|
| 44 |
+
} finally {
|
| 45 |
+
setSaving(false);
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div className={cn('rounded-xl border border-slate-200 bg-white p-4 mb-6', className)}>
|
| 51 |
+
<h4 className="text-sm font-semibold text-slate-800 mb-3">{heading}</h4>
|
| 52 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
| 53 |
+
<div>
|
| 54 |
+
<label className="block text-xs font-medium text-slate-500 mb-1">First name</label>
|
| 55 |
+
<Input
|
| 56 |
+
value={form.firstName}
|
| 57 |
+
onChange={(e) => setField('firstName', e.target.value)}
|
| 58 |
+
disabled={disabled}
|
| 59 |
+
className="text-sm"
|
| 60 |
+
/>
|
| 61 |
+
</div>
|
| 62 |
+
<div>
|
| 63 |
+
<label className="block text-xs font-medium text-slate-500 mb-1">Last name</label>
|
| 64 |
+
<Input
|
| 65 |
+
value={form.lastName}
|
| 66 |
+
onChange={(e) => setField('lastName', e.target.value)}
|
| 67 |
+
disabled={disabled}
|
| 68 |
+
className="text-sm"
|
| 69 |
+
/>
|
| 70 |
+
</div>
|
| 71 |
+
<div className="sm:col-span-2">
|
| 72 |
+
<label className="block text-xs font-medium text-slate-500 mb-1">Email</label>
|
| 73 |
+
<Input
|
| 74 |
+
type="email"
|
| 75 |
+
value={form.email}
|
| 76 |
+
onChange={(e) => setField('email', e.target.value)}
|
| 77 |
+
disabled={disabled}
|
| 78 |
+
className="text-sm"
|
| 79 |
+
/>
|
| 80 |
+
</div>
|
| 81 |
+
<div className="sm:col-span-2">
|
| 82 |
+
<label className="block text-xs font-medium text-slate-500 mb-1">Title</label>
|
| 83 |
+
<Input
|
| 84 |
+
value={form.title}
|
| 85 |
+
onChange={(e) => setField('title', e.target.value)}
|
| 86 |
+
disabled={disabled}
|
| 87 |
+
className="text-sm"
|
| 88 |
+
/>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
<div className="mt-4">
|
| 92 |
+
<Button type="button" className="w-full sm:w-auto" onClick={handleSave} disabled={disabled || saving}>
|
| 93 |
+
{saving ? (
|
| 94 |
+
<>
|
| 95 |
+
<Loader2 className="h-4 w-4 animate-spin mr-2" aria-hidden />
|
| 96 |
+
Saving…
|
| 97 |
+
</>
|
| 98 |
+
) : (
|
| 99 |
+
'Save'
|
| 100 |
+
)}
|
| 101 |
+
</Button>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
);
|
| 105 |
+
}
|
frontend/src/components/workspace/DealLinkSearch.jsx
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { Loader2, Search, UserPlus, Building2 } 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 |
+
/** Search CRM contacts and link one to the deal (`PATCH contact_id`). */
|
| 8 |
+
export function DealContactSearch({ linkedContactId, onPatchDeal, className }) {
|
| 9 |
+
const [q, setQ] = useState('');
|
| 10 |
+
const [results, setResults] = useState([]);
|
| 11 |
+
const [loading, setLoading] = useState(false);
|
| 12 |
+
const [linking, setLinking] = useState(false);
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
if (!q.trim()) {
|
| 16 |
+
setResults([]);
|
| 17 |
+
return;
|
| 18 |
+
}
|
| 19 |
+
const t = setTimeout(async () => {
|
| 20 |
+
setLoading(true);
|
| 21 |
+
try {
|
| 22 |
+
const res = await fetch(
|
| 23 |
+
`/api/contacts?search=${encodeURIComponent(q.trim())}&limit=12&sort_by=created_at&sort_dir=desc`
|
| 24 |
+
);
|
| 25 |
+
const data = await res.json().catch(() => ({}));
|
| 26 |
+
setResults(res.ok ? data.contacts || [] : []);
|
| 27 |
+
} catch {
|
| 28 |
+
setResults([]);
|
| 29 |
+
} finally {
|
| 30 |
+
setLoading(false);
|
| 31 |
+
}
|
| 32 |
+
}, 280);
|
| 33 |
+
return () => clearTimeout(t);
|
| 34 |
+
}, [q]);
|
| 35 |
+
|
| 36 |
+
const pick = async (c) => {
|
| 37 |
+
setLinking(true);
|
| 38 |
+
try {
|
| 39 |
+
await onPatchDeal({ contact_id: c.id });
|
| 40 |
+
setQ('');
|
| 41 |
+
setResults([]);
|
| 42 |
+
} finally {
|
| 43 |
+
setLinking(false);
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const unlink = async () => {
|
| 48 |
+
setLinking(true);
|
| 49 |
+
try {
|
| 50 |
+
await onPatchDeal({ contact_id: null });
|
| 51 |
+
} finally {
|
| 52 |
+
setLinking(false);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
return (
|
| 57 |
+
<div className={cn('rounded-lg border border-slate-200 bg-slate-50/80 p-3 mb-3', className)}>
|
| 58 |
+
<div className="flex items-center gap-2 text-xs font-medium text-slate-600 mb-2">
|
| 59 |
+
<UserPlus className="h-3.5 w-3.5 shrink-0" />
|
| 60 |
+
Link contact from CRM
|
| 61 |
+
</div>
|
| 62 |
+
<div className="relative">
|
| 63 |
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
|
| 64 |
+
<Input
|
| 65 |
+
value={q}
|
| 66 |
+
onChange={(e) => setQ(e.target.value)}
|
| 67 |
+
placeholder="Search name, email, company…"
|
| 68 |
+
className="pl-9 text-sm bg-white"
|
| 69 |
+
disabled={linking}
|
| 70 |
+
/>
|
| 71 |
+
{loading ? (
|
| 72 |
+
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-slate-400" />
|
| 73 |
+
) : null}
|
| 74 |
+
</div>
|
| 75 |
+
{results.length > 0 ? (
|
| 76 |
+
<ul className="mt-2 max-h-40 overflow-y-auto rounded-md border border-slate-200 bg-white text-sm divide-y divide-slate-100">
|
| 77 |
+
{results.map((c) => (
|
| 78 |
+
<li key={c.id}>
|
| 79 |
+
<button
|
| 80 |
+
type="button"
|
| 81 |
+
disabled={linking || linkedContactId === c.id}
|
| 82 |
+
className="w-full text-left px-3 py-2 hover:bg-violet-50 disabled:opacity-50"
|
| 83 |
+
onClick={() => pick(c)}
|
| 84 |
+
>
|
| 85 |
+
<div className="font-medium text-slate-800 truncate">
|
| 86 |
+
{[c.first_name, c.last_name].filter(Boolean).join(' ') || '—'}
|
| 87 |
+
</div>
|
| 88 |
+
<div className="text-xs text-slate-500 truncate">{c.email || '—'}</div>
|
| 89 |
+
<div className="text-xs text-slate-400 truncate">{c.company || ''}</div>
|
| 90 |
+
</button>
|
| 91 |
+
</li>
|
| 92 |
+
))}
|
| 93 |
+
</ul>
|
| 94 |
+
) : null}
|
| 95 |
+
{linkedContactId ? (
|
| 96 |
+
<div className="mt-2 flex justify-end">
|
| 97 |
+
<Button type="button" variant="ghost" size="sm" className="text-slate-600" onClick={unlink} disabled={linking}>
|
| 98 |
+
Remove contact link
|
| 99 |
+
</Button>
|
| 100 |
+
</div>
|
| 101 |
+
) : null}
|
| 102 |
+
</div>
|
| 103 |
+
);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/** Search distinct company names from contacts; sets deal `account_name`. */
|
| 107 |
+
export function DealCompanySearch({ onPatchDeal, className }) {
|
| 108 |
+
const [q, setQ] = useState('');
|
| 109 |
+
const [results, setResults] = useState([]);
|
| 110 |
+
const [loading, setLoading] = useState(false);
|
| 111 |
+
const [applying, setApplying] = useState(false);
|
| 112 |
+
|
| 113 |
+
useEffect(() => {
|
| 114 |
+
if (!q.trim()) {
|
| 115 |
+
setResults([]);
|
| 116 |
+
return;
|
| 117 |
+
}
|
| 118 |
+
const t = setTimeout(async () => {
|
| 119 |
+
setLoading(true);
|
| 120 |
+
try {
|
| 121 |
+
const res = await fetch(`/api/company-names?q=${encodeURIComponent(q.trim())}&limit=20`);
|
| 122 |
+
const data = await res.json().catch(() => ({}));
|
| 123 |
+
setResults(res.ok ? data.names || [] : []);
|
| 124 |
+
} catch {
|
| 125 |
+
setResults([]);
|
| 126 |
+
} finally {
|
| 127 |
+
setLoading(false);
|
| 128 |
+
}
|
| 129 |
+
}, 280);
|
| 130 |
+
return () => clearTimeout(t);
|
| 131 |
+
}, [q]);
|
| 132 |
+
|
| 133 |
+
const pick = async (name) => {
|
| 134 |
+
setApplying(true);
|
| 135 |
+
try {
|
| 136 |
+
await onPatchDeal({ account_name: name });
|
| 137 |
+
setQ('');
|
| 138 |
+
setResults([]);
|
| 139 |
+
} finally {
|
| 140 |
+
setApplying(false);
|
| 141 |
+
}
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
return (
|
| 145 |
+
<div className={cn('rounded-lg border border-slate-200 bg-slate-50/80 p-3 mb-3', className)}>
|
| 146 |
+
<div className="flex items-center gap-2 text-xs font-medium text-slate-600 mb-2">
|
| 147 |
+
<Building2 className="h-3.5 w-3.5 shrink-0" />
|
| 148 |
+
Add company from CRM (account name)
|
| 149 |
+
</div>
|
| 150 |
+
<div className="relative">
|
| 151 |
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
|
| 152 |
+
<Input
|
| 153 |
+
value={q}
|
| 154 |
+
onChange={(e) => setQ(e.target.value)}
|
| 155 |
+
placeholder="Search company names…"
|
| 156 |
+
className="pl-9 text-sm bg-white"
|
| 157 |
+
disabled={applying}
|
| 158 |
+
/>
|
| 159 |
+
{loading ? (
|
| 160 |
+
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-slate-400" />
|
| 161 |
+
) : null}
|
| 162 |
+
</div>
|
| 163 |
+
{results.length > 0 ? (
|
| 164 |
+
<ul className="mt-2 max-h-36 overflow-y-auto rounded-md border border-slate-200 bg-white text-sm divide-y divide-slate-100">
|
| 165 |
+
{results.map((name) => (
|
| 166 |
+
<li key={name}>
|
| 167 |
+
<button
|
| 168 |
+
type="button"
|
| 169 |
+
disabled={applying}
|
| 170 |
+
className="w-full text-left px-3 py-2 hover:bg-violet-50"
|
| 171 |
+
onClick={() => pick(name)}
|
| 172 |
+
>
|
| 173 |
+
{name}
|
| 174 |
+
</button>
|
| 175 |
+
</li>
|
| 176 |
+
))}
|
| 177 |
+
</ul>
|
| 178 |
+
) : null}
|
| 179 |
+
</div>
|
| 180 |
+
);
|
| 181 |
+
}
|
frontend/src/pages/Contacts.jsx
CHANGED
|
@@ -2,9 +2,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
import {
|
| 4 |
Users,
|
| 5 |
-
Mail,
|
| 6 |
-
Building2,
|
| 7 |
-
Briefcase,
|
| 8 |
FileText,
|
| 9 |
SlidersHorizontal,
|
| 10 |
Loader2,
|
|
@@ -13,19 +10,18 @@ import {
|
|
| 13 |
ArrowUpDown,
|
| 14 |
Check,
|
| 15 |
X,
|
| 16 |
-
Sparkles,
|
| 17 |
Trash2,
|
| 18 |
Handshake,
|
| 19 |
Pencil,
|
| 20 |
} from 'lucide-react';
|
| 21 |
import { Input } from '@/components/ui/input';
|
| 22 |
-
import { Badge } from '@/components/ui/badge';
|
| 23 |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
| 24 |
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 CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
|
|
|
| 29 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 30 |
import { cn } from '@/lib/utils';
|
| 31 |
|
|
@@ -968,60 +964,39 @@ export default function Contacts() {
|
|
| 968 |
}
|
| 969 |
subtitle={(selectedContactDetails?.email || selectedContact?.email) || ''}
|
| 970 |
widthClassName="max-w-xl"
|
| 971 |
-
headerActions={
|
| 972 |
-
isManualContact ? (
|
| 973 |
-
<Button
|
| 974 |
-
type="button"
|
| 975 |
-
variant="outline"
|
| 976 |
-
size="sm"
|
| 977 |
-
className="gap-1.5 text-violet-700 border-violet-200 hover:bg-violet-50"
|
| 978 |
-
onClick={enrichContactFromGpt}
|
| 979 |
-
disabled={enrichLoading}
|
| 980 |
-
aria-label="Fetch AI-enriched company and contact details"
|
| 981 |
-
>
|
| 982 |
-
{enrichLoading ? (
|
| 983 |
-
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" aria-hidden />
|
| 984 |
-
) : (
|
| 985 |
-
<Sparkles className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
| 986 |
-
)}
|
| 987 |
-
Fetch
|
| 988 |
-
</Button>
|
| 989 |
-
) : null
|
| 990 |
-
}
|
| 991 |
>
|
| 992 |
{selectedContact && (
|
| 993 |
-
<div>
|
| 994 |
-
{
|
| 995 |
-
<
|
| 996 |
-
{
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
<Badge variant="outline" className="gap-1">
|
| 1001 |
-
<Mail className="h-3 w-3" />
|
| 1002 |
-
{(selectedContactDetails?.email || selectedContact.email) || 'N/A'}
|
| 1003 |
-
</Badge>
|
| 1004 |
-
<Badge variant="outline" className="gap-1">
|
| 1005 |
-
<Building2 className="h-3 w-3" />
|
| 1006 |
-
{(selectedContactDetails?.company || selectedContact.company) || 'N/A'}
|
| 1007 |
-
</Badge>
|
| 1008 |
-
<Badge variant="outline" className="gap-1">
|
| 1009 |
-
<Briefcase className="h-3 w-3" />
|
| 1010 |
-
{(selectedContactDetails?.title || selectedContact.title) || 'N/A'}
|
| 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">
|
| 1026 |
<FileText className="h-4 w-4" />
|
| 1027 |
Generated sequences
|
|
@@ -1051,6 +1026,7 @@ export default function Contacts() {
|
|
| 1051 |
))}
|
| 1052 |
</div>
|
| 1053 |
)}
|
|
|
|
| 1054 |
</div>
|
| 1055 |
)}
|
| 1056 |
</SlideOverPanel>
|
|
|
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
import {
|
| 4 |
Users,
|
|
|
|
|
|
|
|
|
|
| 5 |
FileText,
|
| 6 |
SlidersHorizontal,
|
| 7 |
Loader2,
|
|
|
|
| 10 |
ArrowUpDown,
|
| 11 |
Check,
|
| 12 |
X,
|
|
|
|
| 13 |
Trash2,
|
| 14 |
Handshake,
|
| 15 |
Pencil,
|
| 16 |
} from 'lucide-react';
|
| 17 |
import { Input } from '@/components/ui/input';
|
|
|
|
| 18 |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
| 19 |
import { Button } from '@/components/ui/button';
|
| 20 |
import AppShell from '@/components/layout/AppShell';
|
| 21 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 22 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 23 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 24 |
+
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
| 25 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 26 |
import { cn } from '@/lib/utils';
|
| 27 |
|
|
|
|
| 964 |
}
|
| 965 |
subtitle={(selectedContactDetails?.email || selectedContact?.email) || ''}
|
| 966 |
widthClassName="max-w-xl"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 967 |
>
|
| 968 |
{selectedContact && (
|
| 969 |
+
<div className="flex flex-col">
|
| 970 |
+
{selectedContactDetails && (
|
| 971 |
+
<ContactIdentityEditor
|
| 972 |
+
firstName={selectedContactDetails.first_name || ''}
|
| 973 |
+
lastName={selectedContactDetails.last_name || ''}
|
| 974 |
+
email={selectedContactDetails.email || ''}
|
| 975 |
+
title={selectedContactDetails.title || ''}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 976 |
onSave={(patch) => patchContact(selectedContact.id, patch)}
|
| 977 |
+
disabled={!selectedContact}
|
| 978 |
/>
|
| 979 |
)}
|
| 980 |
|
| 981 |
+
<div className="mt-8 pt-6 border-t border-slate-100">
|
| 982 |
+
{contactCompanyDetailsForEditor && selectedContact && (
|
| 983 |
+
<CompanyDetailsEditor
|
| 984 |
+
variant="contact"
|
| 985 |
+
companyDetails={contactCompanyDetailsForEditor}
|
| 986 |
+
fallbackCompanyName={
|
| 987 |
+
selectedContactDetails?.company || selectedContact.company || ''
|
| 988 |
+
}
|
| 989 |
+
onSave={(patch) => patchContact(selectedContact.id, patch)}
|
| 990 |
+
className="mb-0"
|
| 991 |
+
showFetch={isManualContact}
|
| 992 |
+
onFetch={enrichContactFromGpt}
|
| 993 |
+
fetchLoading={enrichLoading}
|
| 994 |
+
fetchError={enrichError}
|
| 995 |
+
/>
|
| 996 |
+
)}
|
| 997 |
+
</div>
|
| 998 |
+
|
| 999 |
+
<div className="mt-8 pt-6 border-t border-slate-100">
|
| 1000 |
<h4 className="text-sm font-semibold text-slate-800 mb-2 flex items-center gap-2">
|
| 1001 |
<FileText className="h-4 w-4" />
|
| 1002 |
Generated sequences
|
|
|
|
| 1026 |
))}
|
| 1027 |
</div>
|
| 1028 |
)}
|
| 1029 |
+
</div>
|
| 1030 |
</div>
|
| 1031 |
)}
|
| 1032 |
</SlideOverPanel>
|
frontend/src/pages/Deals.jsx
CHANGED
|
@@ -7,6 +7,8 @@ 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';
|
|
@@ -587,6 +589,8 @@ export default function Deals() {
|
|
| 587 |
const [tableEditRowId, setTableEditRowId] = useState(null);
|
| 588 |
const [dealsView, setDealsView] = useState('main');
|
| 589 |
const [createBusy, setCreateBusy] = useState(false);
|
|
|
|
|
|
|
| 590 |
|
| 591 |
const fetchDeals = useCallback(async () => {
|
| 592 |
setLoading(true);
|
|
@@ -753,6 +757,55 @@ export default function Deals() {
|
|
| 753 |
}
|
| 754 |
};
|
| 755 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 756 |
const updateStage = (dealId, stage) => patchDeal(dealId, { stage });
|
| 757 |
|
| 758 |
const openDeal = async (deal, opts = {}) => {
|
|
@@ -1009,15 +1062,39 @@ export default function Deals() {
|
|
| 1009 |
widthClassName="max-w-xl"
|
| 1010 |
>
|
| 1011 |
{dealDetail && (
|
| 1012 |
-
<div className="space-y-
|
| 1013 |
-
<
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1021 |
<div>
|
| 1022 |
<dt className="text-slate-500">Deal value</dt>
|
| 1023 |
<dd className="font-medium tabular-nums">{fmtMoney(dealDetail.deal_value)}</dd>
|
|
@@ -1031,7 +1108,7 @@ export default function Deals() {
|
|
| 1031 |
<dd>{fmtDate(dealDetail.expected_close_date)}</dd>
|
| 1032 |
</div>
|
| 1033 |
<div>
|
| 1034 |
-
<dt className="text-slate-500">Contact</dt>
|
| 1035 |
<dd>{dealDetail.contact_display || '—'}</dd>
|
| 1036 |
</div>
|
| 1037 |
<div>
|
|
@@ -1045,11 +1122,23 @@ export default function Deals() {
|
|
| 1045 |
</div>
|
| 1046 |
) : null}
|
| 1047 |
</dl>
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
</
|
| 1052 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1053 |
</div>
|
| 1054 |
)}
|
| 1055 |
</SlideOverPanel>
|
|
|
|
| 7 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 8 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 9 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 10 |
+
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
| 11 |
+
import { DealContactSearch, DealCompanySearch } from '@/components/workspace/DealLinkSearch';
|
| 12 |
import { EditableCell, EditableDateCell } from '@/components/workspace/EditableCell';
|
| 13 |
import { SearchableCountryPicker } from '@/components/workspace/SearchableCountryPicker';
|
| 14 |
import { cn } from '@/lib/utils';
|
|
|
|
| 589 |
const [tableEditRowId, setTableEditRowId] = useState(null);
|
| 590 |
const [dealsView, setDealsView] = useState('main');
|
| 591 |
const [createBusy, setCreateBusy] = useState(false);
|
| 592 |
+
const [companyFetchLoading, setCompanyFetchLoading] = useState(false);
|
| 593 |
+
const [companyFetchError, setCompanyFetchError] = useState('');
|
| 594 |
|
| 595 |
const fetchDeals = useCallback(async () => {
|
| 596 |
setLoading(true);
|
|
|
|
| 757 |
}
|
| 758 |
};
|
| 759 |
|
| 760 |
+
const refreshDealDetail = async (dealId) => {
|
| 761 |
+
try {
|
| 762 |
+
const res = await fetch(`/api/deals/${dealId}`);
|
| 763 |
+
if (res.ok) {
|
| 764 |
+
const d = await res.json();
|
| 765 |
+
setDealDetail(d);
|
| 766 |
+
}
|
| 767 |
+
} catch (e) {
|
| 768 |
+
console.error(e);
|
| 769 |
+
}
|
| 770 |
+
};
|
| 771 |
+
|
| 772 |
+
const patchLinkedContact = async (contactId, patch) => {
|
| 773 |
+
try {
|
| 774 |
+
const res = await fetch(`/api/contacts/${contactId}`, {
|
| 775 |
+
method: 'PATCH',
|
| 776 |
+
headers: { 'Content-Type': 'application/json' },
|
| 777 |
+
body: JSON.stringify(patch),
|
| 778 |
+
});
|
| 779 |
+
const data = await res.json().catch(() => ({}));
|
| 780 |
+
if (!res.ok) {
|
| 781 |
+
throw new Error(typeof data.detail === 'string' ? data.detail : 'Update failed');
|
| 782 |
+
}
|
| 783 |
+
if (dealDetail?.id) await refreshDealDetail(dealDetail.id);
|
| 784 |
+
} catch (e) {
|
| 785 |
+
console.error(e);
|
| 786 |
+
alert(e.message || 'Could not save contact');
|
| 787 |
+
}
|
| 788 |
+
};
|
| 789 |
+
|
| 790 |
+
const fetchDealCompanyAi = async () => {
|
| 791 |
+
if (!dealDetail?.contact_id) return;
|
| 792 |
+
setCompanyFetchLoading(true);
|
| 793 |
+
setCompanyFetchError('');
|
| 794 |
+
try {
|
| 795 |
+
const res = await fetch(`/api/contacts/${dealDetail.contact_id}/enrich`, { method: 'POST' });
|
| 796 |
+
const data = await res.json().catch(() => ({}));
|
| 797 |
+
if (!res.ok) {
|
| 798 |
+
setCompanyFetchError(typeof data.detail === 'string' ? data.detail : 'Could not fetch enrichment');
|
| 799 |
+
return;
|
| 800 |
+
}
|
| 801 |
+
if (dealDetail?.id) await refreshDealDetail(dealDetail.id);
|
| 802 |
+
} catch (e) {
|
| 803 |
+
setCompanyFetchError(e.message || 'Fetch failed');
|
| 804 |
+
} finally {
|
| 805 |
+
setCompanyFetchLoading(false);
|
| 806 |
+
}
|
| 807 |
+
};
|
| 808 |
+
|
| 809 |
const updateStage = (dealId, stage) => patchDeal(dealId, { stage });
|
| 810 |
|
| 811 |
const openDeal = async (deal, opts = {}) => {
|
|
|
|
| 1062 |
widthClassName="max-w-xl"
|
| 1063 |
>
|
| 1064 |
{dealDetail && (
|
| 1065 |
+
<div className="space-y-6 text-sm">
|
| 1066 |
+
<section>
|
| 1067 |
+
<h4 className="text-sm font-semibold text-slate-800 mb-2">Contact details</h4>
|
| 1068 |
+
<DealContactSearch
|
| 1069 |
+
linkedContactId={dealDetail.contact_id}
|
| 1070 |
+
onPatchDeal={(patch) => patchDeal(dealDetail.id, patch)}
|
| 1071 |
+
/>
|
| 1072 |
+
{dealDetail.linked_contact ? (
|
| 1073 |
+
<>
|
| 1074 |
+
<ContactIdentityEditor
|
| 1075 |
+
firstName={dealDetail.linked_contact.first_name || ''}
|
| 1076 |
+
lastName={dealDetail.linked_contact.last_name || ''}
|
| 1077 |
+
email={dealDetail.linked_contact.email || ''}
|
| 1078 |
+
title={dealDetail.linked_contact.title || ''}
|
| 1079 |
+
onSave={(patch) =>
|
| 1080 |
+
patchLinkedContact(dealDetail.linked_contact.id, patch)
|
| 1081 |
+
}
|
| 1082 |
+
className="mb-0"
|
| 1083 |
+
/>
|
| 1084 |
+
<div className="mt-2">
|
| 1085 |
+
<Button asChild variant="outline" size="sm">
|
| 1086 |
+
<Link to="/contacts">Open in Contacts</Link>
|
| 1087 |
+
</Button>
|
| 1088 |
+
</div>
|
| 1089 |
+
</>
|
| 1090 |
+
) : (
|
| 1091 |
+
<p className="text-xs text-slate-500">
|
| 1092 |
+
Search for a person above to link them to this deal and edit their details.
|
| 1093 |
+
</p>
|
| 1094 |
+
)}
|
| 1095 |
+
</section>
|
| 1096 |
+
|
| 1097 |
+
<dl className="space-y-3 border-t border-slate-100 pt-6">
|
| 1098 |
<div>
|
| 1099 |
<dt className="text-slate-500">Deal value</dt>
|
| 1100 |
<dd className="font-medium tabular-nums">{fmtMoney(dealDetail.deal_value)}</dd>
|
|
|
|
| 1108 |
<dd>{fmtDate(dealDetail.expected_close_date)}</dd>
|
| 1109 |
</div>
|
| 1110 |
<div>
|
| 1111 |
+
<dt className="text-slate-500">Contact (display)</dt>
|
| 1112 |
<dd>{dealDetail.contact_display || '—'}</dd>
|
| 1113 |
</div>
|
| 1114 |
<div>
|
|
|
|
| 1122 |
</div>
|
| 1123 |
) : null}
|
| 1124 |
</dl>
|
| 1125 |
+
|
| 1126 |
+
<section className="border-t border-slate-100 pt-6 mt-2">
|
| 1127 |
+
<h4 className="text-sm font-semibold text-slate-800 mb-2">Company details</h4>
|
| 1128 |
+
<DealCompanySearch onPatchDeal={(patch) => patchDeal(dealDetail.id, patch)} />
|
| 1129 |
+
<CompanyDetailsEditor
|
| 1130 |
+
variant="deal"
|
| 1131 |
+
dealLinkedContact={!!dealDetail.contact_id}
|
| 1132 |
+
companyDetails={dealDetail.company_details}
|
| 1133 |
+
fallbackCompanyName={dealDetail.account_name || ''}
|
| 1134 |
+
onSave={(patch) => patchDeal(dealDetail.id, patch)}
|
| 1135 |
+
className="mb-0"
|
| 1136 |
+
showFetch={!!dealDetail.contact_id}
|
| 1137 |
+
onFetch={fetchDealCompanyAi}
|
| 1138 |
+
fetchLoading={companyFetchLoading}
|
| 1139 |
+
fetchError={companyFetchError}
|
| 1140 |
+
/>
|
| 1141 |
+
</section>
|
| 1142 |
</div>
|
| 1143 |
)}
|
| 1144 |
</SlideOverPanel>
|
frontend/src/pages/Leads.jsx
CHANGED
|
@@ -18,6 +18,7 @@ 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 |
|
|
@@ -64,6 +65,8 @@ export default function Leads() {
|
|
| 64 |
const [selected, setSelected] = useState(null);
|
| 65 |
const [threadLoading, setThreadLoading] = useState(false);
|
| 66 |
const [threadData, setThreadData] = useState(null);
|
|
|
|
|
|
|
| 67 |
const [seedBusy, setSeedBusy] = useState(false);
|
| 68 |
const [rowSelection, setRowSelection] = useState({});
|
| 69 |
const [bulkBusy, setBulkBusy] = useState(null);
|
|
@@ -148,6 +151,28 @@ export default function Leads() {
|
|
| 148 |
}
|
| 149 |
};
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
const updateStatus = (leadId, crmStatus) => patchLead(leadId, { crm_status: crmStatus });
|
| 152 |
|
| 153 |
const loadThread = async (leadId) => {
|
|
@@ -677,40 +702,36 @@ export default function Leads() {
|
|
| 677 |
>
|
| 678 |
{selected && (
|
| 679 |
<>
|
| 680 |
-
<
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
|
|
|
| 684 |
onSave={(patch) => patchLead(selected.id, patch)}
|
| 685 |
/>
|
| 686 |
-
|
| 687 |
-
<
|
| 688 |
-
<
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
</a>
|
| 708 |
-
</dd>
|
| 709 |
-
</div>
|
| 710 |
-
)}
|
| 711 |
-
</dl>
|
| 712 |
|
| 713 |
-
<div className="mt-6">
|
| 714 |
<div className="flex items-center justify-between mb-2">
|
| 715 |
<h4 className="font-semibold text-slate-800">Their reply</h4>
|
| 716 |
<Button
|
|
|
|
| 18 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 19 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 20 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 21 |
+
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
| 22 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 23 |
import { cn } from '@/lib/utils';
|
| 24 |
|
|
|
|
| 65 |
const [selected, setSelected] = useState(null);
|
| 66 |
const [threadLoading, setThreadLoading] = useState(false);
|
| 67 |
const [threadData, setThreadData] = useState(null);
|
| 68 |
+
const [companyFetchLoading, setCompanyFetchLoading] = useState(false);
|
| 69 |
+
const [companyFetchError, setCompanyFetchError] = useState('');
|
| 70 |
const [seedBusy, setSeedBusy] = useState(false);
|
| 71 |
const [rowSelection, setRowSelection] = useState({});
|
| 72 |
const [bulkBusy, setBulkBusy] = useState(null);
|
|
|
|
| 151 |
}
|
| 152 |
};
|
| 153 |
|
| 154 |
+
const fetchLeadCompanyAi = async () => {
|
| 155 |
+
if (!selected?.contact_id) return;
|
| 156 |
+
setCompanyFetchLoading(true);
|
| 157 |
+
setCompanyFetchError('');
|
| 158 |
+
try {
|
| 159 |
+
const res = await fetch(`/api/contacts/${selected.contact_id}/enrich`, { method: 'POST' });
|
| 160 |
+
const data = await res.json().catch(() => ({}));
|
| 161 |
+
if (!res.ok) {
|
| 162 |
+
setCompanyFetchError(typeof data.detail === 'string' ? data.detail : 'Could not fetch enrichment');
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
const res2 = await fetch(`/api/leads/${selected.id}`);
|
| 166 |
+
if (res2.ok) {
|
| 167 |
+
setSelected(await res2.json());
|
| 168 |
+
}
|
| 169 |
+
} catch (e) {
|
| 170 |
+
setCompanyFetchError(e.message || 'Fetch failed');
|
| 171 |
+
} finally {
|
| 172 |
+
setCompanyFetchLoading(false);
|
| 173 |
+
}
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
const updateStatus = (leadId, crmStatus) => patchLead(leadId, { crm_status: crmStatus });
|
| 177 |
|
| 178 |
const loadThread = async (leadId) => {
|
|
|
|
| 702 |
>
|
| 703 |
{selected && (
|
| 704 |
<>
|
| 705 |
+
<ContactIdentityEditor
|
| 706 |
+
firstName={selected.first_name || ''}
|
| 707 |
+
lastName={selected.last_name || ''}
|
| 708 |
+
email={selected.email || ''}
|
| 709 |
+
title={selected.title || ''}
|
| 710 |
onSave={(patch) => patchLead(selected.id, patch)}
|
| 711 |
/>
|
| 712 |
+
{selected.contact ? (
|
| 713 |
+
<p className="text-xs text-violet-700 mb-4">
|
| 714 |
+
<a href="/contacts" className="underline font-medium">
|
| 715 |
+
View linked contact in Contacts
|
| 716 |
+
</a>
|
| 717 |
+
</p>
|
| 718 |
+
) : null}
|
| 719 |
+
|
| 720 |
+
<div className="mt-8 pt-6 border-t border-slate-100">
|
| 721 |
+
<CompanyDetailsEditor
|
| 722 |
+
variant="lead"
|
| 723 |
+
companyDetails={selected.company_details}
|
| 724 |
+
fallbackCompanyName={selected.company_name || ''}
|
| 725 |
+
onSave={(patch) => patchLead(selected.id, patch)}
|
| 726 |
+
className="mb-0"
|
| 727 |
+
showFetch={!!selected.contact_id}
|
| 728 |
+
onFetch={fetchLeadCompanyAi}
|
| 729 |
+
fetchLoading={companyFetchLoading}
|
| 730 |
+
fetchError={companyFetchError}
|
| 731 |
+
/>
|
| 732 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 733 |
|
| 734 |
+
<div className="mt-8 pt-6 border-t border-slate-100">
|
| 735 |
<div className="flex items-center justify-between mb-2">
|
| 736 |
<h4 className="font-semibold text-slate-800">Their reply</h4>
|
| 737 |
<Button
|