Seth Claude Sonnet 5 commited on
Commit ·
41c19e3
1
Parent(s): fe7e466
Add Call/Visit activity logging and timeline for contacts and companies
Browse filesNew ContactActivity table tracks calls/visits with notes and author,
surfaced via Call/Visit icon buttons (with an inline note textarea)
everywhere a contact appears — Contacts table rows, associated-contacts
lists on Companies/Leads/Deals, and the contact identity header in each
detail panel. Adds a per-contact activity timeline and a company-level
rollup that aggregates activity across every contact sharing that company.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- backend/app/database.py +17 -0
- backend/app/main.py +102 -0
- backend/app/models.py +5 -0
- frontend/src/components/workspace/ActivityTimeline.jsx +96 -0
- frontend/src/components/workspace/AssociatedContactsList.jsx +45 -24
- frontend/src/components/workspace/ContactActivityLogger.jsx +133 -0
- frontend/src/components/workspace/ContactIdentityEditor.jsx +12 -6
- frontend/src/pages/Companies.jsx +10 -0
- frontend/src/pages/Contacts.jsx +20 -0
- frontend/src/pages/Deals.jsx +14 -0
- frontend/src/pages/Leads.jsx +14 -0
backend/app/database.py
CHANGED
|
@@ -210,6 +210,23 @@ class Company(Base):
|
|
| 210 |
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 211 |
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
class CrmLead(Base):
|
| 214 |
"""Lead synced from Smartlead replies (webhook) — CRM pipeline status is local."""
|
| 215 |
|
|
|
|
| 210 |
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 211 |
|
| 212 |
|
| 213 |
+
class ContactActivity(Base):
|
| 214 |
+
"""A logged Call or Visit against a Contact — who logged it, what was discussed, when.
|
| 215 |
+
Powers the per-contact and per-company activity timelines. Always tied to a real Contact
|
| 216 |
+
row (never a bare Lead) so company-level rollups can reuse the same case-insensitive
|
| 217 |
+
Contact.company name match already used for associated-contacts lists."""
|
| 218 |
+
|
| 219 |
+
__tablename__ = "contact_activities"
|
| 220 |
+
|
| 221 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 222 |
+
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
| 223 |
+
contact_id = Column(Integer, ForeignKey("contacts.id"), nullable=False, index=True)
|
| 224 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
| 225 |
+
activity_type = Column(String, nullable=False) # "call" | "visit"
|
| 226 |
+
notes = Column(Text, default="")
|
| 227 |
+
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
class CrmLead(Base):
|
| 231 |
"""Lead synced from Smartlead replies (webhook) — CRM pipeline status is local."""
|
| 232 |
|
backend/app/main.py
CHANGED
|
@@ -37,6 +37,7 @@ from .database import (
|
|
| 37 |
SmartleadRun,
|
| 38 |
Contact,
|
| 39 |
Company,
|
|
|
|
| 40 |
CrmLead,
|
| 41 |
CrmDeal,
|
| 42 |
UnipileAccount,
|
|
@@ -56,6 +57,7 @@ from .models import (
|
|
| 56 |
CrmLeadPatchRequest,
|
| 57 |
BulkLeadIdsRequest,
|
| 58 |
BulkContactIdsRequest,
|
|
|
|
| 59 |
CrmDealPatchRequest,
|
| 60 |
CrmDealCreateRequest,
|
| 61 |
ContactCreateRequest,
|
|
@@ -3355,6 +3357,71 @@ async def get_contacts_by_company(
|
|
| 3355 |
}
|
| 3356 |
|
| 3357 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3358 |
@app.post("/api/contacts")
|
| 3359 |
async def create_contact(body: ContactCreateRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 3360 |
"""Create a contact manually (inline table add). Email must be unique."""
|
|
@@ -4461,6 +4528,41 @@ async def get_company(company_id: int, t: TenantContext = Depends(get_tenant_con
|
|
| 4461 |
return d
|
| 4462 |
|
| 4463 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4464 |
@app.patch("/api/companies/{company_id}")
|
| 4465 |
async def patch_company(
|
| 4466 |
company_id: int, body: CompanyPatchRequest, t: TenantContext = Depends(get_tenant_context)
|
|
|
|
| 37 |
SmartleadRun,
|
| 38 |
Contact,
|
| 39 |
Company,
|
| 40 |
+
ContactActivity,
|
| 41 |
CrmLead,
|
| 42 |
CrmDeal,
|
| 43 |
UnipileAccount,
|
|
|
|
| 57 |
CrmLeadPatchRequest,
|
| 58 |
BulkLeadIdsRequest,
|
| 59 |
BulkContactIdsRequest,
|
| 60 |
+
ActivityCreateRequest,
|
| 61 |
CrmDealPatchRequest,
|
| 62 |
CrmDealCreateRequest,
|
| 63 |
ContactCreateRequest,
|
|
|
|
| 3357 |
}
|
| 3358 |
|
| 3359 |
|
| 3360 |
+
def _activity_to_dict(a: ContactActivity, user: User | None, contact: Contact | None = None) -> dict:
|
| 3361 |
+
d = {
|
| 3362 |
+
"id": a.id,
|
| 3363 |
+
"contact_id": a.contact_id,
|
| 3364 |
+
"activity_type": a.activity_type,
|
| 3365 |
+
"notes": a.notes or "",
|
| 3366 |
+
"created_at": a.created_at.isoformat() if a.created_at else None,
|
| 3367 |
+
"user_id": a.user_id,
|
| 3368 |
+
"user_display_name": (user.name or user.email or "").strip() if user else "",
|
| 3369 |
+
"user_initials": _user_initials_from_user(user),
|
| 3370 |
+
}
|
| 3371 |
+
if contact is not None:
|
| 3372 |
+
d["contact_name"] = _format_contact_display(contact)
|
| 3373 |
+
return d
|
| 3374 |
+
|
| 3375 |
+
|
| 3376 |
+
@app.post("/api/contacts/{contact_id}/activities")
|
| 3377 |
+
async def create_contact_activity(
|
| 3378 |
+
contact_id: int, body: ActivityCreateRequest, t: TenantContext = Depends(get_tenant_context)
|
| 3379 |
+
):
|
| 3380 |
+
"""Log a Call or Visit against a contact, with optional notes on what was discussed."""
|
| 3381 |
+
db = t.db
|
| 3382 |
+
contact = (
|
| 3383 |
+
db.query(Contact)
|
| 3384 |
+
.filter(Contact.tenant_id == t.tenant_id, Contact.id == contact_id)
|
| 3385 |
+
.first()
|
| 3386 |
+
)
|
| 3387 |
+
if not contact:
|
| 3388 |
+
raise HTTPException(status_code=404, detail="Contact not found")
|
| 3389 |
+
activity = ContactActivity(
|
| 3390 |
+
tenant_id=t.tenant_id,
|
| 3391 |
+
contact_id=contact_id,
|
| 3392 |
+
user_id=t.user_id,
|
| 3393 |
+
activity_type=body.activity_type,
|
| 3394 |
+
notes=_safe_str(body.notes),
|
| 3395 |
+
)
|
| 3396 |
+
db.add(activity)
|
| 3397 |
+
db.commit()
|
| 3398 |
+
db.refresh(activity)
|
| 3399 |
+
user = db.query(User).filter(User.id == t.user_id).first()
|
| 3400 |
+
return _activity_to_dict(activity, user)
|
| 3401 |
+
|
| 3402 |
+
|
| 3403 |
+
@app.get("/api/contacts/{contact_id}/activities")
|
| 3404 |
+
async def list_contact_activities(contact_id: int, t: TenantContext = Depends(get_tenant_context)):
|
| 3405 |
+
"""Activity timeline (Calls/Visits, with notes and author) for a single contact."""
|
| 3406 |
+
db = t.db
|
| 3407 |
+
contact = (
|
| 3408 |
+
db.query(Contact)
|
| 3409 |
+
.filter(Contact.tenant_id == t.tenant_id, Contact.id == contact_id)
|
| 3410 |
+
.first()
|
| 3411 |
+
)
|
| 3412 |
+
if not contact:
|
| 3413 |
+
raise HTTPException(status_code=404, detail="Contact not found")
|
| 3414 |
+
rows = (
|
| 3415 |
+
db.query(ContactActivity)
|
| 3416 |
+
.filter(ContactActivity.tenant_id == t.tenant_id, ContactActivity.contact_id == contact_id)
|
| 3417 |
+
.order_by(ContactActivity.created_at.desc())
|
| 3418 |
+
.all()
|
| 3419 |
+
)
|
| 3420 |
+
user_ids = {r.user_id for r in rows if r.user_id}
|
| 3421 |
+
users_by_id = {u.id: u for u in db.query(User).filter(User.id.in_(user_ids)).all()} if user_ids else {}
|
| 3422 |
+
return {"activities": [_activity_to_dict(r, users_by_id.get(r.user_id)) for r in rows]}
|
| 3423 |
+
|
| 3424 |
+
|
| 3425 |
@app.post("/api/contacts")
|
| 3426 |
async def create_contact(body: ContactCreateRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 3427 |
"""Create a contact manually (inline table add). Email must be unique."""
|
|
|
|
| 4528 |
return d
|
| 4529 |
|
| 4530 |
|
| 4531 |
+
@app.get("/api/companies/{company_id}/activities")
|
| 4532 |
+
async def list_company_activities(company_id: int, t: TenantContext = Depends(get_tenant_context)):
|
| 4533 |
+
"""Rolled-up activity timeline for a company — every Call/Visit logged against any contact
|
| 4534 |
+
sharing this company's name (same case-insensitive match used for associated contacts)."""
|
| 4535 |
+
db = t.db
|
| 4536 |
+
company = (
|
| 4537 |
+
db.query(Company)
|
| 4538 |
+
.filter(Company.tenant_id == t.tenant_id, Company.id == company_id)
|
| 4539 |
+
.first()
|
| 4540 |
+
)
|
| 4541 |
+
if not company:
|
| 4542 |
+
raise HTTPException(status_code=404, detail="Company not found")
|
| 4543 |
+
contacts = _contacts_for_company(db, t.tenant_id, company.name)
|
| 4544 |
+
contacts_by_id = {c.id: c for c in contacts}
|
| 4545 |
+
if not contacts_by_id:
|
| 4546 |
+
return {"activities": []}
|
| 4547 |
+
rows = (
|
| 4548 |
+
db.query(ContactActivity)
|
| 4549 |
+
.filter(
|
| 4550 |
+
ContactActivity.tenant_id == t.tenant_id,
|
| 4551 |
+
ContactActivity.contact_id.in_(contacts_by_id.keys()),
|
| 4552 |
+
)
|
| 4553 |
+
.order_by(ContactActivity.created_at.desc())
|
| 4554 |
+
.all()
|
| 4555 |
+
)
|
| 4556 |
+
user_ids = {r.user_id for r in rows if r.user_id}
|
| 4557 |
+
users_by_id = {u.id: u for u in db.query(User).filter(User.id.in_(user_ids)).all()} if user_ids else {}
|
| 4558 |
+
return {
|
| 4559 |
+
"activities": [
|
| 4560 |
+
_activity_to_dict(r, users_by_id.get(r.user_id), contacts_by_id.get(r.contact_id))
|
| 4561 |
+
for r in rows
|
| 4562 |
+
]
|
| 4563 |
+
}
|
| 4564 |
+
|
| 4565 |
+
|
| 4566 |
@app.patch("/api/companies/{company_id}")
|
| 4567 |
async def patch_company(
|
| 4568 |
company_id: int, body: CompanyPatchRequest, t: TenantContext = Depends(get_tenant_context)
|
backend/app/models.py
CHANGED
|
@@ -99,6 +99,11 @@ class BulkContactIdsRequest(BaseModel):
|
|
| 99 |
contact_ids: List[int]
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
class CompanyCreateRequest(BaseModel):
|
| 103 |
name: str
|
| 104 |
category: str = ""
|
|
|
|
| 99 |
contact_ids: List[int]
|
| 100 |
|
| 101 |
|
| 102 |
+
class ActivityCreateRequest(BaseModel):
|
| 103 |
+
activity_type: Literal["call", "visit"]
|
| 104 |
+
notes: str = Field(default="", max_length=8000)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
class CompanyCreateRequest(BaseModel):
|
| 108 |
name: str
|
| 109 |
category: str = ""
|
frontend/src/components/workspace/ActivityTimeline.jsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { Phone, MapPin, Loader2 } from 'lucide-react';
|
| 3 |
+
import { apiFetch } from '@/lib/api';
|
| 4 |
+
|
| 5 |
+
function formatWhen(iso) {
|
| 6 |
+
if (!iso) return '';
|
| 7 |
+
const d = new Date(iso);
|
| 8 |
+
if (Number.isNaN(d.getTime())) return '';
|
| 9 |
+
return d.toLocaleString(undefined, {
|
| 10 |
+
month: 'short',
|
| 11 |
+
day: 'numeric',
|
| 12 |
+
hour: 'numeric',
|
| 13 |
+
minute: '2-digit',
|
| 14 |
+
});
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* Chronological Call/Visit activity feed. Self-fetching — pass `endpoint` (the GET URL) and bump
|
| 19 |
+
* `refreshKey` after logging a new activity elsewhere to re-fetch.
|
| 20 |
+
*/
|
| 21 |
+
export default function ActivityTimeline({ endpoint, refreshKey, heading = 'Activity timeline', className }) {
|
| 22 |
+
const [activities, setActivities] = useState([]);
|
| 23 |
+
const [loading, setLoading] = useState(true);
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
if (!endpoint) return;
|
| 27 |
+
let cancelled = false;
|
| 28 |
+
setLoading(true);
|
| 29 |
+
(async () => {
|
| 30 |
+
try {
|
| 31 |
+
const res = await apiFetch(endpoint);
|
| 32 |
+
if (!res.ok) return;
|
| 33 |
+
const data = await res.json();
|
| 34 |
+
if (!cancelled) setActivities(data.activities || []);
|
| 35 |
+
} catch (e) {
|
| 36 |
+
console.error(e);
|
| 37 |
+
} finally {
|
| 38 |
+
if (!cancelled) setLoading(false);
|
| 39 |
+
}
|
| 40 |
+
})();
|
| 41 |
+
return () => {
|
| 42 |
+
cancelled = true;
|
| 43 |
+
};
|
| 44 |
+
}, [endpoint, refreshKey]);
|
| 45 |
+
|
| 46 |
+
if (!endpoint) return null;
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className={className}>
|
| 50 |
+
<h4 className="mb-3 font-semibold text-slate-800">{heading}</h4>
|
| 51 |
+
{loading ? (
|
| 52 |
+
<div className="flex items-center gap-2 text-sm text-slate-500">
|
| 53 |
+
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
| 54 |
+
Loading…
|
| 55 |
+
</div>
|
| 56 |
+
) : activities.length === 0 ? (
|
| 57 |
+
<p className="text-sm text-slate-500">No calls or visits logged yet.</p>
|
| 58 |
+
) : (
|
| 59 |
+
<ul className="space-y-2">
|
| 60 |
+
{activities.map((a) => (
|
| 61 |
+
<li
|
| 62 |
+
key={a.id}
|
| 63 |
+
className="flex gap-2.5 rounded-lg border border-slate-200 bg-white px-3 py-2"
|
| 64 |
+
>
|
| 65 |
+
<div
|
| 66 |
+
className={
|
| 67 |
+
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full ' +
|
| 68 |
+
(a.activity_type === 'call' ? 'bg-emerald-100 text-emerald-700' : 'bg-violet-100 text-violet-700')
|
| 69 |
+
}
|
| 70 |
+
>
|
| 71 |
+
{a.activity_type === 'call' ? (
|
| 72 |
+
<Phone className="h-3.5 w-3.5" aria-hidden />
|
| 73 |
+
) : (
|
| 74 |
+
<MapPin className="h-3.5 w-3.5" aria-hidden />
|
| 75 |
+
)}
|
| 76 |
+
</div>
|
| 77 |
+
<div className="min-w-0 flex-1">
|
| 78 |
+
<p className="text-xs text-slate-500">
|
| 79 |
+
<span className="font-medium text-slate-700">
|
| 80 |
+
{a.activity_type === 'call' ? 'Call' : 'Visit'}
|
| 81 |
+
</span>
|
| 82 |
+
{a.contact_name ? <span> with {a.contact_name}</span> : null}
|
| 83 |
+
{a.user_display_name ? <span> · {a.user_display_name}</span> : null}
|
| 84 |
+
{a.created_at ? <span> · {formatWhen(a.created_at)}</span> : null}
|
| 85 |
+
</p>
|
| 86 |
+
{a.notes ? (
|
| 87 |
+
<p className="mt-0.5 whitespace-pre-wrap text-sm text-slate-800">{a.notes}</p>
|
| 88 |
+
) : null}
|
| 89 |
+
</div>
|
| 90 |
+
</li>
|
| 91 |
+
))}
|
| 92 |
+
</ul>
|
| 93 |
+
)}
|
| 94 |
+
</div>
|
| 95 |
+
);
|
| 96 |
+
}
|
frontend/src/components/workspace/AssociatedContactsList.jsx
CHANGED
|
@@ -1,14 +1,18 @@
|
|
| 1 |
import React from 'react';
|
| 2 |
import { Mail, Phone } from 'lucide-react';
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* Shared "Associated contacts" list for the Companies / Leads / Deals detail slide-overs.
|
| 6 |
* Renders contacts sharing a company (case-insensitive name match, resolved server-side),
|
| 7 |
-
* each row clickable to jump to that contact's own detail panel
|
|
|
|
|
|
|
| 8 |
*/
|
| 9 |
export default function AssociatedContactsList({
|
| 10 |
contacts = [],
|
| 11 |
onContactClick,
|
|
|
|
| 12 |
heading = 'Associated contacts',
|
| 13 |
headerActions = null,
|
| 14 |
emptyMessage = 'No contacts linked to this company yet.',
|
|
@@ -29,29 +33,46 @@ export default function AssociatedContactsList({
|
|
| 29 |
{contacts.map((c) => {
|
| 30 |
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email;
|
| 31 |
return (
|
| 32 |
-
<li
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
{
|
| 41 |
-
{
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
</span>
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
</li>
|
| 56 |
);
|
| 57 |
})}
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
import { Mail, Phone } from 'lucide-react';
|
| 3 |
+
import ContactActivityLogger from '@/components/workspace/ContactActivityLogger';
|
| 4 |
|
| 5 |
/**
|
| 6 |
* Shared "Associated contacts" list for the Companies / Leads / Deals detail slide-overs.
|
| 7 |
* Renders contacts sharing a company (case-insensitive name match, resolved server-side),
|
| 8 |
+
* each row clickable to jump to that contact's own detail panel, with Call/Visit logging
|
| 9 |
+
* icons alongside (kept as sibling interactive elements, not nested inside the row's own
|
| 10 |
+
* clickable area, since a <button> can't contain other <button>s).
|
| 11 |
*/
|
| 12 |
export default function AssociatedContactsList({
|
| 13 |
contacts = [],
|
| 14 |
onContactClick,
|
| 15 |
+
onActivityLogged,
|
| 16 |
heading = 'Associated contacts',
|
| 17 |
headerActions = null,
|
| 18 |
emptyMessage = 'No contacts linked to this company yet.',
|
|
|
|
| 33 |
{contacts.map((c) => {
|
| 34 |
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email;
|
| 35 |
return (
|
| 36 |
+
<li
|
| 37 |
+
key={c.id}
|
| 38 |
+
className="rounded-lg border border-slate-200 bg-white px-3 py-2 hover:bg-violet-50/60 hover:border-violet-200 transition-colors"
|
| 39 |
+
>
|
| 40 |
+
<div className="flex items-start justify-between gap-2">
|
| 41 |
+
<div
|
| 42 |
+
role="button"
|
| 43 |
+
tabIndex={0}
|
| 44 |
+
onClick={() => onContactClick && onContactClick(c)}
|
| 45 |
+
onKeyDown={(e) => {
|
| 46 |
+
if (e.key === 'Enter' || e.key === ' ') {
|
| 47 |
+
e.preventDefault();
|
| 48 |
+
onContactClick && onContactClick(c);
|
| 49 |
+
}
|
| 50 |
+
}}
|
| 51 |
+
className="min-w-0 flex-1 cursor-pointer text-left text-sm"
|
| 52 |
+
>
|
| 53 |
+
<p className="font-medium text-violet-800">{name}</p>
|
| 54 |
+
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-x-3 gap-y-1 mt-0.5">
|
| 55 |
+
{c.title ? <span>{c.title}</span> : null}
|
| 56 |
+
{c.email ? (
|
| 57 |
+
<span className="inline-flex items-center gap-1">
|
| 58 |
+
<Mail className="h-3 w-3" />
|
| 59 |
+
{c.email}
|
| 60 |
+
</span>
|
| 61 |
+
) : null}
|
| 62 |
+
{c.phone ? (
|
| 63 |
+
<span className="inline-flex items-center gap-1">
|
| 64 |
+
<Phone className="h-3 w-3" />
|
| 65 |
+
{c.phone}
|
| 66 |
+
</span>
|
| 67 |
+
) : null}
|
| 68 |
+
</p>
|
| 69 |
+
</div>
|
| 70 |
+
<ContactActivityLogger
|
| 71 |
+
contactId={c.id}
|
| 72 |
+
onLogged={onActivityLogged}
|
| 73 |
+
className="shrink-0"
|
| 74 |
+
/>
|
| 75 |
+
</div>
|
| 76 |
</li>
|
| 77 |
);
|
| 78 |
})}
|
frontend/src/components/workspace/ContactActivityLogger.jsx
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Phone, MapPin, Loader2, X } from 'lucide-react';
|
| 3 |
+
import { Button } from '@/components/ui/button';
|
| 4 |
+
import { apiFetch } from '@/lib/api';
|
| 5 |
+
import { cn } from '@/lib/utils';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* "Call" / "Visit" icon buttons for a contact. Clicking one opens an inline textarea to note
|
| 9 |
+
* what was discussed; saving POSTs to /api/contacts/{contactId}/activities. Self-contained —
|
| 10 |
+
* manages its own open/saving state, calls `onLogged` after a successful save so the parent can
|
| 11 |
+
* refresh whatever activity timeline is showing.
|
| 12 |
+
*/
|
| 13 |
+
export default function ContactActivityLogger({ contactId, onLogged, className, iconSize = 'h-3.5 w-3.5' }) {
|
| 14 |
+
const [openType, setOpenType] = useState(null); // 'call' | 'visit' | null
|
| 15 |
+
const [notes, setNotes] = useState('');
|
| 16 |
+
const [saving, setSaving] = useState(false);
|
| 17 |
+
const [error, setError] = useState('');
|
| 18 |
+
|
| 19 |
+
if (!contactId) return null;
|
| 20 |
+
|
| 21 |
+
const toggle = (type) => (e) => {
|
| 22 |
+
e.stopPropagation();
|
| 23 |
+
setError('');
|
| 24 |
+
setOpenType((cur) => (cur === type ? null : type));
|
| 25 |
+
setNotes('');
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const cancel = (e) => {
|
| 29 |
+
e.stopPropagation();
|
| 30 |
+
setOpenType(null);
|
| 31 |
+
setNotes('');
|
| 32 |
+
setError('');
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const save = async (e) => {
|
| 36 |
+
e.stopPropagation();
|
| 37 |
+
if (!openType) return;
|
| 38 |
+
setSaving(true);
|
| 39 |
+
setError('');
|
| 40 |
+
try {
|
| 41 |
+
const res = await apiFetch(`/api/contacts/${contactId}/activities`, {
|
| 42 |
+
method: 'POST',
|
| 43 |
+
headers: { 'Content-Type': 'application/json' },
|
| 44 |
+
body: JSON.stringify({ activity_type: openType, notes: notes.trim() }),
|
| 45 |
+
});
|
| 46 |
+
const data = await res.json().catch(() => ({}));
|
| 47 |
+
if (!res.ok) {
|
| 48 |
+
setError(typeof data.detail === 'string' ? data.detail : 'Could not save activity');
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
setOpenType(null);
|
| 52 |
+
setNotes('');
|
| 53 |
+
onLogged && onLogged(data);
|
| 54 |
+
} catch (err) {
|
| 55 |
+
console.error(err);
|
| 56 |
+
setError('Network error');
|
| 57 |
+
} finally {
|
| 58 |
+
setSaving(false);
|
| 59 |
+
}
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className={className} onClick={(e) => e.stopPropagation()}>
|
| 64 |
+
<div className="flex items-center gap-1">
|
| 65 |
+
<Button
|
| 66 |
+
type="button"
|
| 67 |
+
variant="ghost"
|
| 68 |
+
size="icon"
|
| 69 |
+
className={cn(
|
| 70 |
+
'h-7 w-7 text-slate-500 hover:text-emerald-700 hover:bg-emerald-50',
|
| 71 |
+
openType === 'call' && 'bg-emerald-100 text-emerald-800 hover:bg-emerald-100'
|
| 72 |
+
)}
|
| 73 |
+
title="Log a call"
|
| 74 |
+
aria-label="Log a call"
|
| 75 |
+
onClick={toggle('call')}
|
| 76 |
+
>
|
| 77 |
+
<Phone className={iconSize} aria-hidden />
|
| 78 |
+
</Button>
|
| 79 |
+
<Button
|
| 80 |
+
type="button"
|
| 81 |
+
variant="ghost"
|
| 82 |
+
size="icon"
|
| 83 |
+
className={cn(
|
| 84 |
+
'h-7 w-7 text-slate-500 hover:text-violet-700 hover:bg-violet-50',
|
| 85 |
+
openType === 'visit' && 'bg-violet-100 text-violet-800 hover:bg-violet-100'
|
| 86 |
+
)}
|
| 87 |
+
title="Log a visit"
|
| 88 |
+
aria-label="Log a visit"
|
| 89 |
+
onClick={toggle('visit')}
|
| 90 |
+
>
|
| 91 |
+
<MapPin className={iconSize} aria-hidden />
|
| 92 |
+
</Button>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{openType ? (
|
| 96 |
+
<div className="mt-2 w-72 rounded-lg border border-slate-200 bg-white p-2 shadow-sm">
|
| 97 |
+
<div className="mb-1 flex items-center justify-between">
|
| 98 |
+
<span className="text-xs font-medium text-slate-600">
|
| 99 |
+
{openType === 'call' ? 'What did you discuss on the call?' : 'What did you discuss on the visit?'}
|
| 100 |
+
</span>
|
| 101 |
+
<button
|
| 102 |
+
type="button"
|
| 103 |
+
className="text-slate-400 hover:text-slate-600"
|
| 104 |
+
onClick={cancel}
|
| 105 |
+
aria-label="Cancel"
|
| 106 |
+
>
|
| 107 |
+
<X className="h-3.5 w-3.5" aria-hidden />
|
| 108 |
+
</button>
|
| 109 |
+
</div>
|
| 110 |
+
<textarea
|
| 111 |
+
autoFocus
|
| 112 |
+
rows={2}
|
| 113 |
+
className="w-full resize-none rounded-md border border-slate-200 px-2 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-violet-400"
|
| 114 |
+
placeholder="Notes (optional)"
|
| 115 |
+
value={notes}
|
| 116 |
+
onChange={(e) => setNotes(e.target.value)}
|
| 117 |
+
disabled={saving}
|
| 118 |
+
onClick={(e) => e.stopPropagation()}
|
| 119 |
+
/>
|
| 120 |
+
{error ? <p className="mt-1 text-xs text-red-600">{error}</p> : null}
|
| 121 |
+
<div className="mt-1.5 flex justify-end gap-1.5">
|
| 122 |
+
<Button type="button" size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={cancel} disabled={saving}>
|
| 123 |
+
Cancel
|
| 124 |
+
</Button>
|
| 125 |
+
<Button type="button" size="sm" className="h-7 px-2 text-xs" onClick={save} disabled={saving}>
|
| 126 |
+
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : 'Save'}
|
| 127 |
+
</Button>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
) : null}
|
| 131 |
+
</div>
|
| 132 |
+
);
|
| 133 |
+
}
|
frontend/src/components/workspace/ContactIdentityEditor.jsx
CHANGED
|
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef } 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 |
/** API/CSV may return numbers; coerce before trim/compare so Inputs never throw. */
|
|
@@ -40,6 +41,8 @@ export default function ContactIdentityEditor({
|
|
| 40 |
email = '',
|
| 41 |
title = '',
|
| 42 |
phone = '',
|
|
|
|
|
|
|
| 43 |
onSave,
|
| 44 |
disabled = false,
|
| 45 |
className,
|
|
@@ -106,12 +109,15 @@ export default function ContactIdentityEditor({
|
|
| 106 |
<div className={cn('rounded-xl border border-slate-200 bg-white p-4 mb-6', className)}>
|
| 107 |
<div className="mb-3 flex items-center justify-between gap-2">
|
| 108 |
<h4 className="text-sm font-semibold text-slate-800">{heading}</h4>
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
<
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
| 115 |
</div>
|
| 116 |
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
| 117 |
<div>
|
|
|
|
| 2 |
import { Loader2 } from 'lucide-react';
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
import { Input } from '@/components/ui/input';
|
| 5 |
+
import ContactActivityLogger from '@/components/workspace/ContactActivityLogger';
|
| 6 |
import { cn } from '@/lib/utils';
|
| 7 |
|
| 8 |
/** API/CSV may return numbers; coerce before trim/compare so Inputs never throw. */
|
|
|
|
| 41 |
email = '',
|
| 42 |
title = '',
|
| 43 |
phone = '',
|
| 44 |
+
contactId = null,
|
| 45 |
+
onActivityLogged,
|
| 46 |
onSave,
|
| 47 |
disabled = false,
|
| 48 |
className,
|
|
|
|
| 109 |
<div className={cn('rounded-xl border border-slate-200 bg-white p-4 mb-6', className)}>
|
| 110 |
<div className="mb-3 flex items-center justify-between gap-2">
|
| 111 |
<h4 className="text-sm font-semibold text-slate-800">{heading}</h4>
|
| 112 |
+
<div className="flex items-center gap-2">
|
| 113 |
+
{autoSave && saving ? (
|
| 114 |
+
<span className="flex items-center gap-1 text-xs text-slate-500">
|
| 115 |
+
<Loader2 className="h-3 w-3 animate-spin shrink-0" aria-hidden />
|
| 116 |
+
Saving…
|
| 117 |
+
</span>
|
| 118 |
+
) : null}
|
| 119 |
+
<ContactActivityLogger contactId={contactId} onLogged={onActivityLogged} />
|
| 120 |
+
</div>
|
| 121 |
</div>
|
| 122 |
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
| 123 |
<div>
|
frontend/src/pages/Companies.jsx
CHANGED
|
@@ -18,6 +18,7 @@ import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
|
| 18 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 19 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 20 |
import AssociatedContactsList from '@/components/workspace/AssociatedContactsList';
|
|
|
|
| 21 |
import { cn } from '@/lib/utils';
|
| 22 |
import { apiFetch } from '@/lib/api';
|
| 23 |
|
|
@@ -46,6 +47,7 @@ export default function Companies() {
|
|
| 46 |
const [bulkBusy, setBulkBusy] = useState(false);
|
| 47 |
const [syncBusy, setSyncBusy] = useState(false);
|
| 48 |
const [convertBusy, setConvertBusy] = useState(false);
|
|
|
|
| 49 |
|
| 50 |
const [addingInline, setAddingInline] = useState(false);
|
| 51 |
const [inlineDraft, setInlineDraft] = useState({ name: '', category: '', type: '' });
|
|
@@ -644,6 +646,7 @@ export default function Companies() {
|
|
| 644 |
<AssociatedContactsList
|
| 645 |
contacts={selected.contacts || []}
|
| 646 |
onContactClick={(c) => navigate(`/contacts?contact=${c.id}`)}
|
|
|
|
| 647 |
className="mb-4"
|
| 648 |
headerActions={
|
| 649 |
<Button
|
|
@@ -724,6 +727,13 @@ export default function Companies() {
|
|
| 724 |
</Button>
|
| 725 |
</div>
|
| 726 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
</>
|
| 728 |
)}
|
| 729 |
</SlideOverPanel>
|
|
|
|
| 18 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 19 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 20 |
import AssociatedContactsList from '@/components/workspace/AssociatedContactsList';
|
| 21 |
+
import ActivityTimeline from '@/components/workspace/ActivityTimeline';
|
| 22 |
import { cn } from '@/lib/utils';
|
| 23 |
import { apiFetch } from '@/lib/api';
|
| 24 |
|
|
|
|
| 47 |
const [bulkBusy, setBulkBusy] = useState(false);
|
| 48 |
const [syncBusy, setSyncBusy] = useState(false);
|
| 49 |
const [convertBusy, setConvertBusy] = useState(false);
|
| 50 |
+
const [activityRefreshKey, setActivityRefreshKey] = useState(0);
|
| 51 |
|
| 52 |
const [addingInline, setAddingInline] = useState(false);
|
| 53 |
const [inlineDraft, setInlineDraft] = useState({ name: '', category: '', type: '' });
|
|
|
|
| 646 |
<AssociatedContactsList
|
| 647 |
contacts={selected.contacts || []}
|
| 648 |
onContactClick={(c) => navigate(`/contacts?contact=${c.id}`)}
|
| 649 |
+
onActivityLogged={() => setActivityRefreshKey((k) => k + 1)}
|
| 650 |
className="mb-4"
|
| 651 |
headerActions={
|
| 652 |
<Button
|
|
|
|
| 727 |
</Button>
|
| 728 |
</div>
|
| 729 |
</div>
|
| 730 |
+
|
| 731 |
+
<div className="mt-8 pt-6 border-t border-slate-100">
|
| 732 |
+
<ActivityTimeline
|
| 733 |
+
endpoint={`/api/companies/${selected.id}/activities`}
|
| 734 |
+
refreshKey={activityRefreshKey}
|
| 735 |
+
/>
|
| 736 |
+
</div>
|
| 737 |
</>
|
| 738 |
)}
|
| 739 |
</SlideOverPanel>
|
frontend/src/pages/Contacts.jsx
CHANGED
|
@@ -24,6 +24,8 @@ import { apiFetch } from '@/lib/api';
|
|
| 24 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 25 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 26 |
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
|
|
|
|
|
|
| 27 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 28 |
import { cn } from '@/lib/utils';
|
| 29 |
|
|
@@ -74,6 +76,7 @@ export default function Contacts() {
|
|
| 74 |
const [bulkBusy, setBulkBusy] = useState(null);
|
| 75 |
/** Inline inputs only for this row id; pencil toggles. Row click opens slide-over. */
|
| 76 |
const [tableEditRowId, setTableEditRowId] = useState(null);
|
|
|
|
| 77 |
|
| 78 |
const selectedIds = useMemo(
|
| 79 |
() => Object.keys(rowSelection).filter((id) => rowSelection[id]).map(Number),
|
|
@@ -791,6 +794,7 @@ export default function Contacts() {
|
|
| 791 |
<SortHeader field="email" label="Email" />
|
| 792 |
<SortHeader field="company" label="Company" />
|
| 793 |
<SortHeader field="title" label="Title" />
|
|
|
|
| 794 |
</tr>
|
| 795 |
</thead>
|
| 796 |
<tbody>
|
|
@@ -913,6 +917,7 @@ export default function Contacts() {
|
|
| 913 |
<p className="text-xs text-red-600 mt-1 w-full">{inlineError}</p>
|
| 914 |
) : null}
|
| 915 |
</td>
|
|
|
|
| 916 |
</tr>
|
| 917 |
)}
|
| 918 |
{contacts.map((contact) => {
|
|
@@ -1051,6 +1056,12 @@ export default function Contacts() {
|
|
| 1051 |
</div>
|
| 1052 |
)}
|
| 1053 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1054 |
</tr>
|
| 1055 |
);
|
| 1056 |
})}
|
|
@@ -1124,6 +1135,8 @@ export default function Contacts() {
|
|
| 1124 |
email={selectedContactDetails.email || ''}
|
| 1125 |
title={selectedContactDetails.title || ''}
|
| 1126 |
phone={selectedContactDetails.phone || ''}
|
|
|
|
|
|
|
| 1127 |
onSave={(patch) => patchContact(selectedContact.id, patch)}
|
| 1128 |
disabled={!selectedContact}
|
| 1129 |
autoSave
|
|
@@ -1149,6 +1162,13 @@ export default function Contacts() {
|
|
| 1149 |
)}
|
| 1150 |
</div>
|
| 1151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1152 |
<div className="mt-8 pt-6 border-t border-slate-100">
|
| 1153 |
<h4 className="text-sm font-semibold text-slate-800 mb-2 flex items-center gap-2">
|
| 1154 |
<FileText className="h-4 w-4" />
|
|
|
|
| 24 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 25 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 26 |
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
| 27 |
+
import ContactActivityLogger from '@/components/workspace/ContactActivityLogger';
|
| 28 |
+
import ActivityTimeline from '@/components/workspace/ActivityTimeline';
|
| 29 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 30 |
import { cn } from '@/lib/utils';
|
| 31 |
|
|
|
|
| 76 |
const [bulkBusy, setBulkBusy] = useState(null);
|
| 77 |
/** Inline inputs only for this row id; pencil toggles. Row click opens slide-over. */
|
| 78 |
const [tableEditRowId, setTableEditRowId] = useState(null);
|
| 79 |
+
const [activityRefreshKey, setActivityRefreshKey] = useState(0);
|
| 80 |
|
| 81 |
const selectedIds = useMemo(
|
| 82 |
() => Object.keys(rowSelection).filter((id) => rowSelection[id]).map(Number),
|
|
|
|
| 794 |
<SortHeader field="email" label="Email" />
|
| 795 |
<SortHeader field="company" label="Company" />
|
| 796 |
<SortHeader field="title" label="Title" />
|
| 797 |
+
<th className="px-3 py-2 font-medium">Activity</th>
|
| 798 |
</tr>
|
| 799 |
</thead>
|
| 800 |
<tbody>
|
|
|
|
| 917 |
<p className="text-xs text-red-600 mt-1 w-full">{inlineError}</p>
|
| 918 |
) : null}
|
| 919 |
</td>
|
| 920 |
+
<td className="px-3 py-2 align-top" aria-hidden />
|
| 921 |
</tr>
|
| 922 |
)}
|
| 923 |
{contacts.map((contact) => {
|
|
|
|
| 1056 |
</div>
|
| 1057 |
)}
|
| 1058 |
</td>
|
| 1059 |
+
<td className="px-3 py-2 align-top" onClick={(e) => e.stopPropagation()}>
|
| 1060 |
+
<ContactActivityLogger
|
| 1061 |
+
contactId={contact.id}
|
| 1062 |
+
onLogged={() => setActivityRefreshKey((k) => k + 1)}
|
| 1063 |
+
/>
|
| 1064 |
+
</td>
|
| 1065 |
</tr>
|
| 1066 |
);
|
| 1067 |
})}
|
|
|
|
| 1135 |
email={selectedContactDetails.email || ''}
|
| 1136 |
title={selectedContactDetails.title || ''}
|
| 1137 |
phone={selectedContactDetails.phone || ''}
|
| 1138 |
+
contactId={selectedContact.id}
|
| 1139 |
+
onActivityLogged={() => setActivityRefreshKey((k) => k + 1)}
|
| 1140 |
onSave={(patch) => patchContact(selectedContact.id, patch)}
|
| 1141 |
disabled={!selectedContact}
|
| 1142 |
autoSave
|
|
|
|
| 1162 |
)}
|
| 1163 |
</div>
|
| 1164 |
|
| 1165 |
+
<div className="mt-8 pt-6 border-t border-slate-100">
|
| 1166 |
+
<ActivityTimeline
|
| 1167 |
+
endpoint={`/api/contacts/${selectedContact.id}/activities`}
|
| 1168 |
+
refreshKey={activityRefreshKey}
|
| 1169 |
+
/>
|
| 1170 |
+
</div>
|
| 1171 |
+
|
| 1172 |
<div className="mt-8 pt-6 border-t border-slate-100">
|
| 1173 |
<h4 className="text-sm font-semibold text-slate-800 mb-2 flex items-center gap-2">
|
| 1174 |
<FileText className="h-4 w-4" />
|
frontend/src/pages/Deals.jsx
CHANGED
|
@@ -11,6 +11,7 @@ import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
|
| 11 |
import WonBillingModal from '@/components/workspace/WonBillingModal';
|
| 12 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 13 |
import AssociatedContactsList from '@/components/workspace/AssociatedContactsList';
|
|
|
|
| 14 |
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
| 15 |
import { DealContactSearch, DealCompanySearch } from '@/components/workspace/DealLinkSearch';
|
| 16 |
import { EditableCell, EditableCurrencyCell, EditableDateCell } from '@/components/workspace/EditableCell';
|
|
@@ -935,6 +936,7 @@ export default function Deals() {
|
|
| 935 |
const [companyFetchLoading, setCompanyFetchLoading] = useState(false);
|
| 936 |
const [companyFetchError, setCompanyFetchError] = useState('');
|
| 937 |
const [associatedContacts, setAssociatedContacts] = useState([]);
|
|
|
|
| 938 |
const [me, setMe] = useState(null);
|
| 939 |
const [tenantMembers, setTenantMembers] = useState([]);
|
| 940 |
const [selectedDealIds, setSelectedDealIds] = useState([]);
|
|
@@ -2121,6 +2123,8 @@ export default function Deals() {
|
|
| 2121 |
email={dealDetail.linked_contact.email || ''}
|
| 2122 |
title={dealDetail.linked_contact.title || ''}
|
| 2123 |
phone={dealDetail.linked_contact.phone || ''}
|
|
|
|
|
|
|
| 2124 |
onSave={(patch) =>
|
| 2125 |
patchLinkedContact(dealDetail.linked_contact.id, patch)
|
| 2126 |
}
|
|
@@ -2163,6 +2167,16 @@ export default function Deals() {
|
|
| 2163 |
<AssociatedContactsList
|
| 2164 |
contacts={associatedContacts}
|
| 2165 |
onContactClick={(c) => navigate(`/contacts?contact=${c.id}`)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2166 |
/>
|
| 2167 |
</section>
|
| 2168 |
) : null}
|
|
|
|
| 11 |
import WonBillingModal from '@/components/workspace/WonBillingModal';
|
| 12 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 13 |
import AssociatedContactsList from '@/components/workspace/AssociatedContactsList';
|
| 14 |
+
import ActivityTimeline from '@/components/workspace/ActivityTimeline';
|
| 15 |
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
| 16 |
import { DealContactSearch, DealCompanySearch } from '@/components/workspace/DealLinkSearch';
|
| 17 |
import { EditableCell, EditableCurrencyCell, EditableDateCell } from '@/components/workspace/EditableCell';
|
|
|
|
| 936 |
const [companyFetchLoading, setCompanyFetchLoading] = useState(false);
|
| 937 |
const [companyFetchError, setCompanyFetchError] = useState('');
|
| 938 |
const [associatedContacts, setAssociatedContacts] = useState([]);
|
| 939 |
+
const [activityRefreshKey, setActivityRefreshKey] = useState(0);
|
| 940 |
const [me, setMe] = useState(null);
|
| 941 |
const [tenantMembers, setTenantMembers] = useState([]);
|
| 942 |
const [selectedDealIds, setSelectedDealIds] = useState([]);
|
|
|
|
| 2123 |
email={dealDetail.linked_contact.email || ''}
|
| 2124 |
title={dealDetail.linked_contact.title || ''}
|
| 2125 |
phone={dealDetail.linked_contact.phone || ''}
|
| 2126 |
+
contactId={dealDetail.linked_contact.id}
|
| 2127 |
+
onActivityLogged={() => setActivityRefreshKey((k) => k + 1)}
|
| 2128 |
onSave={(patch) =>
|
| 2129 |
patchLinkedContact(dealDetail.linked_contact.id, patch)
|
| 2130 |
}
|
|
|
|
| 2167 |
<AssociatedContactsList
|
| 2168 |
contacts={associatedContacts}
|
| 2169 |
onContactClick={(c) => navigate(`/contacts?contact=${c.id}`)}
|
| 2170 |
+
onActivityLogged={() => setActivityRefreshKey((k) => k + 1)}
|
| 2171 |
+
/>
|
| 2172 |
+
</section>
|
| 2173 |
+
) : null}
|
| 2174 |
+
|
| 2175 |
+
{dealDetail.linked_contact?.id ? (
|
| 2176 |
+
<section className="border-t border-slate-100 pt-6 mt-2">
|
| 2177 |
+
<ActivityTimeline
|
| 2178 |
+
endpoint={`/api/contacts/${dealDetail.linked_contact.id}/activities`}
|
| 2179 |
+
refreshKey={activityRefreshKey}
|
| 2180 |
/>
|
| 2181 |
</section>
|
| 2182 |
) : null}
|
frontend/src/pages/Leads.jsx
CHANGED
|
@@ -20,6 +20,7 @@ import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
|
| 20 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 21 |
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
| 22 |
import AssociatedContactsList from '@/components/workspace/AssociatedContactsList';
|
|
|
|
| 23 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 24 |
import { cn } from '@/lib/utils';
|
| 25 |
import { apiFetch } from '@/lib/api';
|
|
@@ -70,6 +71,7 @@ export default function Leads() {
|
|
| 70 |
const [companyFetchLoading, setCompanyFetchLoading] = useState(false);
|
| 71 |
const [companyFetchError, setCompanyFetchError] = useState('');
|
| 72 |
const [associatedContacts, setAssociatedContacts] = useState([]);
|
|
|
|
| 73 |
const [seedBusy, setSeedBusy] = useState(false);
|
| 74 |
const [rowSelection, setRowSelection] = useState({});
|
| 75 |
const [bulkBusy, setBulkBusy] = useState(null);
|
|
@@ -741,6 +743,8 @@ export default function Leads() {
|
|
| 741 |
email={selected.email || ''}
|
| 742 |
title={selected.title || ''}
|
| 743 |
phone={selected.phone || ''}
|
|
|
|
|
|
|
| 744 |
onSave={(patch) => patchLead(selected.id, patch)}
|
| 745 |
autoSave
|
| 746 |
/>
|
|
@@ -772,6 +776,16 @@ export default function Leads() {
|
|
| 772 |
<AssociatedContactsList
|
| 773 |
contacts={associatedContacts}
|
| 774 |
onContactClick={(c) => navigate(`/contacts?contact=${c.id}`)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
/>
|
| 776 |
</div>
|
| 777 |
) : null}
|
|
|
|
| 20 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 21 |
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
| 22 |
import AssociatedContactsList from '@/components/workspace/AssociatedContactsList';
|
| 23 |
+
import ActivityTimeline from '@/components/workspace/ActivityTimeline';
|
| 24 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 25 |
import { cn } from '@/lib/utils';
|
| 26 |
import { apiFetch } from '@/lib/api';
|
|
|
|
| 71 |
const [companyFetchLoading, setCompanyFetchLoading] = useState(false);
|
| 72 |
const [companyFetchError, setCompanyFetchError] = useState('');
|
| 73 |
const [associatedContacts, setAssociatedContacts] = useState([]);
|
| 74 |
+
const [activityRefreshKey, setActivityRefreshKey] = useState(0);
|
| 75 |
const [seedBusy, setSeedBusy] = useState(false);
|
| 76 |
const [rowSelection, setRowSelection] = useState({});
|
| 77 |
const [bulkBusy, setBulkBusy] = useState(null);
|
|
|
|
| 743 |
email={selected.email || ''}
|
| 744 |
title={selected.title || ''}
|
| 745 |
phone={selected.phone || ''}
|
| 746 |
+
contactId={selected.contact_id || null}
|
| 747 |
+
onActivityLogged={() => setActivityRefreshKey((k) => k + 1)}
|
| 748 |
onSave={(patch) => patchLead(selected.id, patch)}
|
| 749 |
autoSave
|
| 750 |
/>
|
|
|
|
| 776 |
<AssociatedContactsList
|
| 777 |
contacts={associatedContacts}
|
| 778 |
onContactClick={(c) => navigate(`/contacts?contact=${c.id}`)}
|
| 779 |
+
onActivityLogged={() => setActivityRefreshKey((k) => k + 1)}
|
| 780 |
+
/>
|
| 781 |
+
</div>
|
| 782 |
+
) : null}
|
| 783 |
+
|
| 784 |
+
{selected.contact_id ? (
|
| 785 |
+
<div className="mt-8 pt-6 border-t border-slate-100">
|
| 786 |
+
<ActivityTimeline
|
| 787 |
+
endpoint={`/api/contacts/${selected.contact_id}/activities`}
|
| 788 |
+
refreshKey={activityRefreshKey}
|
| 789 |
/>
|
| 790 |
</div>
|
| 791 |
) : null}
|