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 +101 -0
- frontend/src/pages/Companies.jsx +72 -21
- frontend/src/pages/Contacts.jsx +20 -1
|
@@ -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()
|
|
@@ -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 |
-
<
|
| 564 |
-
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
<
|
| 583 |
-
|
| 584 |
-
className="inline-flex items-center gap-1
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
</
|
| 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 |
})}
|
|
@@ -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);
|