Seth commited on
Commit
2a3a36f
·
1 Parent(s): 230079f
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={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">
 
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
- {enrichError ? (
995
- <p className="mb-3 text-sm text-red-600" role="alert">
996
- {enrichError}
997
- </p>
998
- ) : null}
999
- <div className="mb-6 flex flex-wrap gap-2 text-xs text-slate-600">
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-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>
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
- {dealDetail.contact_id ? (
1049
- <Button asChild variant="outline" size="sm">
1050
- <Link to="/contacts">View linked contact</Link>
1051
- </Button>
1052
- ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
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
- <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>
689
- <dd className="font-medium">
690
- {[selected.first_name, selected.last_name].filter(Boolean).join(' ') || '—'}
691
- </dd>
692
- </div>
693
- <div>
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>
700
- </div>
701
- {selected.contact && (
702
- <div>
703
- <dt className="text-slate-500">Linked contact</dt>
704
- <dd>
705
- <a href="/contacts" className="text-violet-600 hover:underline">
706
- View in Contacts
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