Seth Claude Sonnet 5 commited on
Commit
6a71a79
·
1 Parent(s): c253255

Sync Category across Company/Contact/Lead, link contacts, add company-level lead conversion

Browse files

- Category is now a true company-level attribute: editing it from the
Company page, a Contact, a Lead, or a Deal linked to a contact
propagates to the Company row and every Contact/Lead sharing that
company name (case-insensitive), so it never drifts between the
four places it's editable.
- Fixed a gap where converting a contact to a lead didn't carry its
Category into the lead's company snapshot.
- Associated contacts in the Company detail panel are now clickable —
navigates to Contacts and opens that exact contact's detail panel
(via a short-lived ?contact= deep link, cleaned from the URL after
use).
- New "Convert to Leads" action on the Company detail panel, mirroring
the existing per-contact "Copy to Leads," but scoped to every
contact associated with that company in one action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

backend/app/main.py CHANGED
@@ -1045,6 +1045,7 @@ def _convert_contact_to_lead_core(db: Session, contact: Contact) -> dict:
1045
  "City": _safe_str(rd.get("City")),
1046
  "State": _safe_str(rd.get("State")),
1047
  "Country": _safe_str(rd.get("Country")),
 
1048
  }
1049
  raw = {
1050
  "source": "contacts_convert",
@@ -3875,6 +3876,8 @@ async def patch_contact(contact_id: int, body: ContactPatchRequest, t: TenantCon
3875
  contact.title = _safe_str(data["title"])
3876
  _sync_contact_raw_from_columns(contact)
3877
  _merge_contact_raw_company_details(contact, data)
 
 
3878
  db.commit()
3879
  db.refresh(contact)
3880
  return {
@@ -4143,6 +4146,7 @@ def _sync_companies_from_contacts(db: Session, tenant_id: int) -> int:
4143
  Company(
4144
  tenant_id=tenant_id,
4145
  name=name,
 
4146
  company_name_for_emails=details.get("Company Name for Emails", ""),
4147
  industry=details.get("Industry", ""),
4148
  employees=details.get("# Employees", ""),
@@ -4161,6 +4165,67 @@ def _sync_companies_from_contacts(db: Session, tenant_id: int) -> int:
4161
  return created
4162
 
4163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4164
  @app.get("/api/companies")
4165
  async def list_companies(
4166
  search: str = Query("", description="Search by name/industry/city/country"),
@@ -4342,6 +4407,8 @@ async def patch_company(
4342
  if field in data:
4343
  setattr(company, field, _safe_str(data[field]))
4344
  company.updated_at = datetime.utcnow()
 
 
4345
  db.commit()
4346
  db.refresh(company)
4347
  contacts = _contacts_for_company(db, t.tenant_id, company.name)
@@ -4409,6 +4476,36 @@ async def create_company_contact(
4409
  }
4410
 
4411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4412
  @app.post("/api/save-prompts")
4413
  async def save_prompts(request: PromptSaveRequest, t: TenantContext = Depends(get_tenant_context)):
4414
  """Save prompt templates for products"""
@@ -5832,6 +5929,8 @@ async def patch_lead(lead_id: int, body: CrmLeadPatchRequest, t: TenantContext =
5832
  rw["company_details"] = cd
5833
  row.raw_webhook = rw
5834
  row.updated_at = datetime.utcnow()
 
 
5835
  db.commit()
5836
  db.refresh(row)
5837
  return _crm_lead_to_dict(row)
@@ -6367,6 +6466,8 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, t: TenantContext =
6367
  rd = dict(c.raw_data or {})
6368
  rd["Country"] = _safe_str(data["country"])
6369
  c.raw_data = rd
 
 
6370
  db.add(c)
6371
  row.updated_at = datetime.utcnow()
6372
  db.commit()
 
1045
  "City": _safe_str(rd.get("City")),
1046
  "State": _safe_str(rd.get("State")),
1047
  "Country": _safe_str(rd.get("Country")),
1048
+ "Category": _safe_str(rd.get("Category")),
1049
  }
1050
  raw = {
1051
  "source": "contacts_convert",
 
3876
  contact.title = _safe_str(data["title"])
3877
  _sync_contact_raw_from_columns(contact)
3878
  _merge_contact_raw_company_details(contact, data)
3879
+ if "category" in data:
3880
+ _propagate_category_everywhere(db, t.tenant_id, contact.company, data["category"])
3881
  db.commit()
3882
  db.refresh(contact)
3883
  return {
 
4146
  Company(
4147
  tenant_id=tenant_id,
4148
  name=name,
4149
+ category=details.get("Category", ""),
4150
  company_name_for_emails=details.get("Company Name for Emails", ""),
4151
  industry=details.get("Industry", ""),
4152
  employees=details.get("# Employees", ""),
 
4165
  return created
4166
 
4167
 
4168
+ def _propagate_category_to_contacts_and_leads(
4169
+ db: Session, tenant_id: int, company_name: str, category: str
4170
+ ) -> None:
4171
+ """Category is a company-level attribute — whenever it's set anywhere (the Company page, or
4172
+ a Contact/Lead/Deal's Company details card), every other record sharing that company name
4173
+ (case-insensitive) is updated to match, so it never drifts between the four places it's
4174
+ editable. Does NOT touch the Company row itself — callers that aren't already the Company
4175
+ row use _propagate_category_everywhere for that."""
4176
+ name = _safe_str(company_name).strip()
4177
+ if not name:
4178
+ return
4179
+ category = _safe_str(category)
4180
+
4181
+ contacts = (
4182
+ db.query(Contact)
4183
+ .filter(Contact.tenant_id == tenant_id, func.lower(func.trim(Contact.company)) == name.lower())
4184
+ .all()
4185
+ )
4186
+ for c in contacts:
4187
+ rd = dict(c.raw_data or {})
4188
+ rd["Category"] = category
4189
+ c.raw_data = rd
4190
+
4191
+ leads = (
4192
+ db.query(CrmLead)
4193
+ .filter(
4194
+ CrmLead.tenant_id == tenant_id,
4195
+ func.lower(func.trim(CrmLead.company_name)) == name.lower(),
4196
+ )
4197
+ .all()
4198
+ )
4199
+ for lead in leads:
4200
+ rw = dict(lead.raw_webhook) if isinstance(lead.raw_webhook, dict) else {}
4201
+ cd = dict(rw.get("company_details") or {}) if isinstance(rw.get("company_details"), dict) else {}
4202
+ cd["Category"] = category
4203
+ rw["company_details"] = cd
4204
+ lead.raw_webhook = rw
4205
+ lead.updated_at = datetime.utcnow()
4206
+
4207
+ db_commit_with_retry(db)
4208
+
4209
+
4210
+ def _propagate_category_everywhere(db: Session, tenant_id: int, company_name: str, category: str) -> None:
4211
+ """Like _propagate_category_to_contacts_and_leads, but also upserts the Company row itself —
4212
+ for callers (Contact/Lead/Deal patches) that aren't already editing the Company row directly."""
4213
+ name = _safe_str(company_name).strip()
4214
+ if not name:
4215
+ return
4216
+ company = (
4217
+ db.query(Company)
4218
+ .filter(Company.tenant_id == tenant_id, func.lower(Company.name) == name.lower())
4219
+ .first()
4220
+ )
4221
+ if company:
4222
+ company.category = _safe_str(category)
4223
+ company.updated_at = datetime.utcnow()
4224
+ else:
4225
+ db.add(Company(tenant_id=tenant_id, name=name, category=_safe_str(category), source="synced_from_contacts"))
4226
+ _propagate_category_to_contacts_and_leads(db, tenant_id, name, category)
4227
+
4228
+
4229
  @app.get("/api/companies")
4230
  async def list_companies(
4231
  search: str = Query("", description="Search by name/industry/city/country"),
 
4407
  if field in data:
4408
  setattr(company, field, _safe_str(data[field]))
4409
  company.updated_at = datetime.utcnow()
4410
+ if "category" in data:
4411
+ _propagate_category_to_contacts_and_leads(db, t.tenant_id, company.name, data["category"])
4412
  db.commit()
4413
  db.refresh(company)
4414
  contacts = _contacts_for_company(db, t.tenant_id, company.name)
 
4476
  }
4477
 
4478
 
4479
+ @app.post("/api/companies/{company_id}/convert-to-leads")
4480
+ async def convert_company_contacts_to_leads(
4481
+ company_id: int, t: TenantContext = Depends(get_tenant_context)
4482
+ ):
4483
+ """Converts every contact associated with this company into a lead — same per-contact rules
4484
+ as POST /api/contacts/bulk-convert-to-leads (skips contacts with no email or where a lead
4485
+ with that email already exists). Contacts are not removed."""
4486
+ db = t.db
4487
+ company = (
4488
+ db.query(Company)
4489
+ .filter(Company.tenant_id == t.tenant_id, Company.id == company_id)
4490
+ .first()
4491
+ )
4492
+ if not company:
4493
+ raise HTTPException(status_code=404, detail="Company not found")
4494
+ contacts = _contacts_for_company(db, t.tenant_id, company.name)
4495
+ if not contacts:
4496
+ return {"converted": 0, "errors": []}
4497
+ converted = 0
4498
+ errors: List[dict] = []
4499
+ for contact in contacts:
4500
+ r = _convert_contact_to_lead_core(db, contact)
4501
+ if not r["ok"]:
4502
+ errors.append({"contact_id": contact.id, "error": r.get("error", "failed")})
4503
+ else:
4504
+ converted += 1
4505
+ db.commit()
4506
+ return {"converted": converted, "errors": errors}
4507
+
4508
+
4509
  @app.post("/api/save-prompts")
4510
  async def save_prompts(request: PromptSaveRequest, t: TenantContext = Depends(get_tenant_context)):
4511
  """Save prompt templates for products"""
 
5929
  rw["company_details"] = cd
5930
  row.raw_webhook = rw
5931
  row.updated_at = datetime.utcnow()
5932
+ if "category" in data:
5933
+ _propagate_category_everywhere(db, t.tenant_id, row.company_name, data["category"])
5934
  db.commit()
5935
  db.refresh(row)
5936
  return _crm_lead_to_dict(row)
 
6466
  rd = dict(c.raw_data or {})
6467
  rd["Country"] = _safe_str(data["country"])
6468
  c.raw_data = rd
6469
+ if "category" in data:
6470
+ _propagate_category_everywhere(db, t.tenant_id, c.company, data["category"])
6471
  db.add(c)
6472
  row.updated_at = datetime.utcnow()
6473
  db.commit()
frontend/src/pages/Companies.jsx CHANGED
@@ -1,4 +1,5 @@
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 
2
  import {
3
  Building2,
4
  Globe,
@@ -8,6 +9,7 @@ import {
8
  X,
9
  Mail,
10
  Users,
 
11
  } from 'lucide-react';
12
  import { Button } from '@/components/ui/button';
13
  import { Input } from '@/components/ui/input';
@@ -30,6 +32,7 @@ function isRowUiTarget(e) {
30
  }
31
 
32
  export default function Companies() {
 
33
  const [companies, setCompanies] = useState([]);
34
  const [total, setTotal] = useState(0);
35
  const [loading, setLoading] = useState(true);
@@ -40,6 +43,7 @@ export default function Companies() {
40
  const [rowSelection, setRowSelection] = useState({});
41
  const [bulkBusy, setBulkBusy] = useState(false);
42
  const [syncBusy, setSyncBusy] = useState(false);
 
43
 
44
  const [addingInline, setAddingInline] = useState(false);
45
  const [inlineDraft, setInlineDraft] = useState({ name: '', category: '' });
@@ -188,6 +192,35 @@ export default function Companies() {
188
  }
189
  };
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  const beginAddCompany = () => {
192
  setAddingInline(true);
193
  setInlineDraft({ name: '', category: '' });
@@ -560,9 +593,27 @@ export default function Companies() {
560
  />
561
 
562
  <div className="mt-8 pt-6 border-t border-slate-100">
563
- <h4 className="mb-3 font-semibold text-slate-800">
564
- Associated contacts ({(selected.contacts || []).length})
565
- </h4>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  {(selected.contacts || []).length === 0 ? (
567
  <p className="text-sm text-slate-500 mb-4">No contacts linked to this company yet.</p>
568
  ) : (
@@ -570,24 +621,24 @@ export default function Companies() {
570
  {selected.contacts.map((c) => {
571
  const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email;
572
  return (
573
- <li
574
- key={c.id}
575
- className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
576
- >
577
- <p className="font-medium text-slate-800">{name}</p>
578
- <p className="text-xs text-slate-500 flex items-center gap-1.5">
579
- {c.title ? <span>{c.title}</span> : null}
580
- {c.title && c.email ? <span>·</span> : null}
581
- {c.email ? (
582
- <a
583
- href={`mailto:${c.email}`}
584
- className="inline-flex items-center gap-1 text-violet-600 hover:underline"
585
- >
586
- <Mail className="h-3 w-3" />
587
- {c.email}
588
- </a>
589
- ) : null}
590
- </p>
591
  </li>
592
  );
593
  })}
 
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
  import {
4
  Building2,
5
  Globe,
 
9
  X,
10
  Mail,
11
  Users,
12
+ Handshake,
13
  } from 'lucide-react';
14
  import { Button } from '@/components/ui/button';
15
  import { Input } from '@/components/ui/input';
 
32
  }
33
 
34
  export default function Companies() {
35
+ const navigate = useNavigate();
36
  const [companies, setCompanies] = useState([]);
37
  const [total, setTotal] = useState(0);
38
  const [loading, setLoading] = useState(true);
 
43
  const [rowSelection, setRowSelection] = useState({});
44
  const [bulkBusy, setBulkBusy] = useState(false);
45
  const [syncBusy, setSyncBusy] = useState(false);
46
+ const [convertBusy, setConvertBusy] = useState(false);
47
 
48
  const [addingInline, setAddingInline] = useState(false);
49
  const [inlineDraft, setInlineDraft] = useState({ name: '', category: '' });
 
192
  }
193
  };
194
 
195
+ const convertToLeads = async () => {
196
+ if (!selected) return;
197
+ setConvertBusy(true);
198
+ try {
199
+ const res = await apiFetch(`/api/companies/${selected.id}/convert-to-leads`, { method: 'POST' });
200
+ const data = await res.json().catch(() => ({}));
201
+ if (!res.ok) {
202
+ throw new Error(typeof data.detail === 'string' ? data.detail : 'Convert failed');
203
+ }
204
+ const conv = data.converted ?? 0;
205
+ if (conv === 0) {
206
+ alert(
207
+ data.errors?.length
208
+ ? 'No contacts were converted. Typical reasons: missing email, or a lead with the same email already exists. See console for per-row errors.'
209
+ : 'This company has no contacts to convert.'
210
+ );
211
+ if (data.errors?.length) console.warn(data.errors);
212
+ return;
213
+ }
214
+ if (data.errors?.length) console.warn(data.errors);
215
+ navigate('/leads');
216
+ } catch (e) {
217
+ console.error(e);
218
+ alert(e.message || 'Could not convert to leads');
219
+ } finally {
220
+ setConvertBusy(false);
221
+ }
222
+ };
223
+
224
  const beginAddCompany = () => {
225
  setAddingInline(true);
226
  setInlineDraft({ name: '', category: '' });
 
593
  />
594
 
595
  <div className="mt-8 pt-6 border-t border-slate-100">
596
+ <div className="mb-3 flex items-center justify-between gap-2">
597
+ <h4 className="font-semibold text-slate-800">
598
+ Associated contacts ({(selected.contacts || []).length})
599
+ </h4>
600
+ <Button
601
+ type="button"
602
+ variant="outline"
603
+ size="sm"
604
+ className="gap-1.5 text-violet-700 border-violet-200 hover:bg-violet-50"
605
+ title="Convert this company's contacts to leads (contacts remain in Contacts)"
606
+ disabled={convertBusy || (selected.contacts || []).length === 0}
607
+ onClick={convertToLeads}
608
+ >
609
+ {convertBusy ? (
610
+ <Loader2 className="h-4 w-4 animate-spin" />
611
+ ) : (
612
+ <Handshake className="h-4 w-4" />
613
+ )}
614
+ Convert to Leads
615
+ </Button>
616
+ </div>
617
  {(selected.contacts || []).length === 0 ? (
618
  <p className="text-sm text-slate-500 mb-4">No contacts linked to this company yet.</p>
619
  ) : (
 
621
  {selected.contacts.map((c) => {
622
  const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email;
623
  return (
624
+ <li key={c.id}>
625
+ <button
626
+ type="button"
627
+ onClick={() => navigate(`/contacts?contact=${c.id}`)}
628
+ className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-left text-sm hover:bg-violet-50/60 hover:border-violet-200 transition-colors"
629
+ >
630
+ <p className="font-medium text-violet-800">{name}</p>
631
+ <p className="text-xs text-slate-500 flex items-center gap-1.5">
632
+ {c.title ? <span>{c.title}</span> : null}
633
+ {c.title && c.email ? <span>·</span> : null}
634
+ {c.email ? (
635
+ <span className="inline-flex items-center gap-1">
636
+ <Mail className="h-3 w-3" />
637
+ {c.email}
638
+ </span>
639
+ ) : null}
640
+ </p>
641
+ </button>
642
  </li>
643
  );
644
  })}
frontend/src/pages/Contacts.jsx CHANGED
@@ -1,5 +1,5 @@
1
  import React, { useEffect, useMemo, useRef, useState } from 'react';
2
- import { useNavigate } from 'react-router-dom';
3
  import {
4
  Users,
5
  FileText,
@@ -40,6 +40,7 @@ function makeFilterRow() {
40
 
41
  export default function Contacts() {
42
  const navigate = useNavigate();
 
43
  const [contacts, setContacts] = useState([]);
44
  const [fields, setFields] = useState([]);
45
  const [selectedContact, setSelectedContact] = useState(null);
@@ -266,6 +267,24 @@ export default function Contacts() {
266
  }
267
  };
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  const closePanel = () => {
270
  setTableEditRowId(null);
271
  setSelectedContact(null);
 
1
  import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useNavigate, useSearchParams } from 'react-router-dom';
3
  import {
4
  Users,
5
  FileText,
 
40
 
41
  export default function Contacts() {
42
  const navigate = useNavigate();
43
+ const [searchParams, setSearchParams] = useSearchParams();
44
  const [contacts, setContacts] = useState([]);
45
  const [fields, setFields] = useState([]);
46
  const [selectedContact, setSelectedContact] = useState(null);
 
267
  }
268
  };
269
 
270
+ // Deep-link support: /contacts?contact=<id> (used by the Companies page's associated-contact
271
+ // list) opens that contact's detail panel on load, then clears the param so a later refresh
272
+ // doesn't keep re-opening it.
273
+ useEffect(() => {
274
+ const contactId = searchParams.get('contact');
275
+ if (!contactId) return;
276
+ openContact({ id: Number(contactId) });
277
+ setSearchParams(
278
+ (prev) => {
279
+ const next = new URLSearchParams(prev);
280
+ next.delete('contact');
281
+ return next;
282
+ },
283
+ { replace: true }
284
+ );
285
+ // eslint-disable-next-line react-hooks/exhaustive-deps
286
+ }, []);
287
+
288
  const closePanel = () => {
289
  setTableEditRowId(null);
290
  setSelectedContact(null);