Seth Claude Sonnet 5 commited on
Commit
41c19e3
·
1 Parent(s): fe7e466

Add Call/Visit activity logging and timeline for contacts and companies

Browse files

New 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 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 key={c.id}>
33
- <button
34
- type="button"
35
- onClick={() => onContactClick && onContactClick(c)}
36
- 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"
37
- >
38
- <p className="font-medium text-violet-800">{name}</p>
39
- <p className="text-xs text-slate-500 flex flex-wrap items-center gap-x-3 gap-y-1 mt-0.5">
40
- {c.title ? <span>{c.title}</span> : null}
41
- {c.email ? (
42
- <span className="inline-flex items-center gap-1">
43
- <Mail className="h-3 w-3" />
44
- {c.email}
45
- </span>
46
- ) : null}
47
- {c.phone ? (
48
- <span className="inline-flex items-center gap-1">
49
- <Phone className="h-3 w-3" />
50
- {c.phone}
51
- </span>
52
- ) : null}
53
- </p>
54
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- {autoSave && saving ? (
110
- <span className="flex items-center gap-1 text-xs text-slate-500">
111
- <Loader2 className="h-3 w-3 animate-spin shrink-0" aria-hidden />
112
- Saving…
113
- </span>
114
- ) : null}
 
 
 
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}