Seth commited on
Commit
45ad5e2
·
1 Parent(s): bbafea0
backend/app/main.py CHANGED
@@ -40,6 +40,7 @@ from .models import (
40
  BulkContactIdsRequest,
41
  CrmDealPatchRequest,
42
  ContactCreateRequest,
 
43
  )
44
  from .gpt_service import generate_email_sequence, enrich_manual_contact_profile
45
  from .smartlead_client import SmartleadClient
@@ -726,6 +727,62 @@ async def get_contact(contact_id: int, db: Session = Depends(get_db)):
726
  }
727
 
728
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729
  @app.post("/api/contacts/{contact_id}/enrich")
730
  async def enrich_contact(contact_id: int, db: Session = Depends(get_db)):
731
  """
@@ -1705,15 +1762,49 @@ async def get_lead(lead_id: int, db: Session = Depends(get_db)):
1705
 
1706
  @app.patch("/api/leads/{lead_id}")
1707
  async def patch_lead(lead_id: int, body: CrmLeadPatchRequest, db: Session = Depends(get_db)):
1708
- if body.crm_status not in CRM_STATUS_ALLOWED:
1709
- raise HTTPException(
1710
- status_code=400,
1711
- detail=f"crm_status must be one of: {sorted(CRM_STATUS_ALLOWED)}",
1712
- )
1713
  row = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
1714
  if not row:
1715
  raise HTTPException(status_code=404, detail="Lead not found")
1716
- row.crm_status = body.crm_status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1717
  db.commit()
1718
  db.refresh(row)
1719
  return _crm_lead_to_dict(row)
@@ -1849,22 +1940,37 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, db: Session = Depe
1849
  row = db.query(CrmDeal).filter(CrmDeal.id == deal_id).first()
1850
  if not row:
1851
  raise HTTPException(status_code=404, detail="Deal not found")
1852
- if body.stage is not None:
1853
- if body.stage not in DEAL_STAGE_ALLOWED:
 
 
 
 
 
 
 
 
 
 
 
1854
  raise HTTPException(
1855
  status_code=400,
1856
  detail=f"stage must be one of: {sorted(DEAL_STAGE_ALLOWED)}",
1857
  )
1858
- row.stage = body.stage
1859
- if body.deal_value is not None:
1860
- row.deal_value = body.deal_value
1861
- if body.close_probability is not None:
1862
- row.close_probability = max(0, min(100, body.close_probability))
1863
- if body.country is not None:
1864
- row.country = body.country
1865
- if body.expected_close_date is not None:
1866
- ts = _to_datetime(body.expected_close_date)
1867
  row.expected_close_date = ts
 
 
 
 
1868
  db.commit()
1869
  db.refresh(row)
1870
  return _deal_to_dict(row)
 
40
  BulkContactIdsRequest,
41
  CrmDealPatchRequest,
42
  ContactCreateRequest,
43
+ ContactPatchRequest,
44
  )
45
  from .gpt_service import generate_email_sequence, enrich_manual_contact_profile
46
  from .smartlead_client import SmartleadClient
 
727
  }
728
 
729
 
730
+ def _sync_contact_raw_from_columns(contact: Contact) -> None:
731
+ rd = dict(contact.raw_data or {})
732
+ rd["First Name"] = contact.first_name or ""
733
+ rd["Last Name"] = contact.last_name or ""
734
+ rd["Email"] = (contact.email or "").strip()
735
+ rd["Company Name"] = contact.company or ""
736
+ rd["Title"] = contact.title or ""
737
+ contact.raw_data = rd
738
+
739
+
740
+ @app.patch("/api/contacts/{contact_id}")
741
+ async def patch_contact(contact_id: int, body: ContactPatchRequest, db: Session = Depends(get_db)):
742
+ contact = db.query(Contact).filter(Contact.id == contact_id).first()
743
+ if not contact:
744
+ raise HTTPException(status_code=404, detail="Contact not found")
745
+ data = body.model_dump(exclude_unset=True)
746
+ if not data:
747
+ raise HTTPException(status_code=400, detail="No fields to update")
748
+ if "email" in data:
749
+ email = _safe_str(data["email"]).lower()
750
+ if not email:
751
+ raise HTTPException(status_code=400, detail="Email cannot be empty")
752
+ taken = (
753
+ db.query(Contact)
754
+ .filter(Contact.id != contact_id, func.lower(Contact.email) == email)
755
+ .first()
756
+ )
757
+ if taken:
758
+ raise HTTPException(status_code=409, detail="Another contact already uses this email")
759
+ contact.email = email
760
+ if "first_name" in data:
761
+ contact.first_name = _safe_str(data["first_name"])
762
+ if "last_name" in data:
763
+ contact.last_name = _safe_str(data["last_name"])
764
+ if "company" in data:
765
+ contact.company = _safe_str(data["company"])
766
+ if "title" in data:
767
+ contact.title = _safe_str(data["title"])
768
+ _sync_contact_raw_from_columns(contact)
769
+ db.commit()
770
+ db.refresh(contact)
771
+ return {
772
+ "id": contact.id,
773
+ "file_id": contact.file_id,
774
+ "row_index": contact.row_index,
775
+ "first_name": contact.first_name or _pick_from_raw(contact.raw_data, ["first name", "firstname", "first_name"]),
776
+ "last_name": contact.last_name or _pick_from_raw(contact.raw_data, ["last name", "lastname", "last_name"]),
777
+ "email": contact.email or _pick_from_raw(contact.raw_data, ["email", "work email", "email address", "secondary email", "tertiary email"]),
778
+ "company": contact.company or _pick_from_raw(contact.raw_data, ["company", "company name", "company name for emails", "organization name", "account name"]),
779
+ "title": contact.title or _pick_from_raw(contact.raw_data, ["title", "job title"]),
780
+ "source": contact.source or "apollo_csv",
781
+ "created_at": contact.created_at.isoformat() if contact.created_at else None,
782
+ "raw_data": contact.raw_data or {},
783
+ }
784
+
785
+
786
  @app.post("/api/contacts/{contact_id}/enrich")
787
  async def enrich_contact(contact_id: int, db: Session = Depends(get_db)):
788
  """
 
1762
 
1763
  @app.patch("/api/leads/{lead_id}")
1764
  async def patch_lead(lead_id: int, body: CrmLeadPatchRequest, db: Session = Depends(get_db)):
 
 
 
 
 
1765
  row = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
1766
  if not row:
1767
  raise HTTPException(status_code=404, detail="Lead not found")
1768
+ data = body.model_dump(exclude_unset=True)
1769
+ if not data:
1770
+ raise HTTPException(status_code=400, detail="No fields to update")
1771
+ if "crm_status" in data:
1772
+ if data["crm_status"] not in CRM_STATUS_ALLOWED:
1773
+ raise HTTPException(
1774
+ status_code=400,
1775
+ detail=f"crm_status must be one of: {sorted(CRM_STATUS_ALLOWED)}",
1776
+ )
1777
+ row.crm_status = data["crm_status"]
1778
+ if "first_name" in data:
1779
+ row.first_name = _safe_str(data["first_name"])
1780
+ if "last_name" in data:
1781
+ row.last_name = _safe_str(data["last_name"])
1782
+ if "company_name" in data:
1783
+ row.company_name = _safe_str(data["company_name"])
1784
+ if "title" in data:
1785
+ row.title = _safe_str(data["title"])
1786
+ if "last_reply_subject" in data:
1787
+ row.last_reply_subject = _safe_str(data["last_reply_subject"])
1788
+ if "last_reply_body" in data:
1789
+ row.last_reply_body = _safe_str(data["last_reply_body"])
1790
+ if "email" in data:
1791
+ email = _safe_str(data["email"]).lower()
1792
+ if email:
1793
+ taken = (
1794
+ db.query(CrmLead)
1795
+ .filter(
1796
+ CrmLead.id != lead_id,
1797
+ func.lower(CrmLead.email) == email,
1798
+ )
1799
+ .first()
1800
+ )
1801
+ if taken:
1802
+ raise HTTPException(
1803
+ status_code=409,
1804
+ detail="Another lead already uses this email",
1805
+ )
1806
+ row.email = email
1807
+ row.updated_at = datetime.utcnow()
1808
  db.commit()
1809
  db.refresh(row)
1810
  return _crm_lead_to_dict(row)
 
1940
  row = db.query(CrmDeal).filter(CrmDeal.id == deal_id).first()
1941
  if not row:
1942
  raise HTTPException(status_code=404, detail="Deal not found")
1943
+ data = body.model_dump(exclude_unset=True)
1944
+ if not data:
1945
+ raise HTTPException(status_code=400, detail="No fields to update")
1946
+ if "name" in data:
1947
+ row.name = _safe_str(data["name"])
1948
+ if "owner_initials" in data:
1949
+ row.owner_initials = _safe_str(data["owner_initials"])[:8]
1950
+ if "contact_display" in data:
1951
+ row.contact_display = _safe_str(data["contact_display"])
1952
+ if "account_name" in data:
1953
+ row.account_name = _safe_str(data["account_name"])
1954
+ if "stage" in data:
1955
+ if data["stage"] not in DEAL_STAGE_ALLOWED:
1956
  raise HTTPException(
1957
  status_code=400,
1958
  detail=f"stage must be one of: {sorted(DEAL_STAGE_ALLOWED)}",
1959
  )
1960
+ row.stage = data["stage"]
1961
+ if "deal_value" in data:
1962
+ row.deal_value = data["deal_value"]
1963
+ if "close_probability" in data:
1964
+ row.close_probability = max(0, min(100, int(data["close_probability"])))
1965
+ if "country" in data:
1966
+ row.country = _safe_str(data["country"])
1967
+ if "expected_close_date" in data:
1968
+ ts = _to_datetime(data["expected_close_date"])
1969
  row.expected_close_date = ts
1970
+ if "last_interaction_at" in data:
1971
+ ts = _to_datetime(data["last_interaction_at"])
1972
+ row.last_interaction_at = ts
1973
+ row.updated_at = datetime.utcnow()
1974
  db.commit()
1975
  db.refresh(row)
1976
  return _deal_to_dict(row)
backend/app/models.py CHANGED
@@ -34,7 +34,14 @@ class SmartleadPushRequest(BaseModel):
34
 
35
 
36
  class CrmLeadPatchRequest(BaseModel):
37
- crm_status: str
 
 
 
 
 
 
 
38
 
39
 
40
  class ContactCreateRequest(BaseModel):
@@ -45,6 +52,14 @@ class ContactCreateRequest(BaseModel):
45
  title: str = ""
46
 
47
 
 
 
 
 
 
 
 
 
48
  class BulkLeadIdsRequest(BaseModel):
49
  lead_ids: List[int]
50
 
@@ -54,11 +69,16 @@ class BulkContactIdsRequest(BaseModel):
54
 
55
 
56
  class CrmDealPatchRequest(BaseModel):
 
57
  stage: Optional[str] = None
 
58
  deal_value: Optional[int] = None
59
  close_probability: Optional[int] = None
60
  expected_close_date: Optional[str] = None # ISO date or datetime
61
  country: Optional[str] = None
 
 
 
62
 
63
 
64
  class SmartleadRunResponse(BaseModel):
 
34
 
35
 
36
  class CrmLeadPatchRequest(BaseModel):
37
+ crm_status: Optional[str] = None
38
+ first_name: Optional[str] = None
39
+ last_name: Optional[str] = None
40
+ email: Optional[str] = None
41
+ company_name: Optional[str] = None
42
+ title: Optional[str] = None
43
+ last_reply_subject: Optional[str] = None
44
+ last_reply_body: Optional[str] = None
45
 
46
 
47
  class ContactCreateRequest(BaseModel):
 
52
  title: str = ""
53
 
54
 
55
+ class ContactPatchRequest(BaseModel):
56
+ first_name: Optional[str] = None
57
+ last_name: Optional[str] = None
58
+ email: Optional[str] = None
59
+ company: Optional[str] = None
60
+ title: Optional[str] = None
61
+
62
+
63
  class BulkLeadIdsRequest(BaseModel):
64
  lead_ids: List[int]
65
 
 
69
 
70
 
71
  class CrmDealPatchRequest(BaseModel):
72
+ name: Optional[str] = None
73
  stage: Optional[str] = None
74
+ owner_initials: Optional[str] = None
75
  deal_value: Optional[int] = None
76
  close_probability: Optional[int] = None
77
  expected_close_date: Optional[str] = None # ISO date or datetime
78
  country: Optional[str] = None
79
+ contact_display: Optional[str] = None
80
+ account_name: Optional[str] = None
81
+ last_interaction_at: Optional[str] = None # ISO date or datetime
82
 
83
 
84
  class SmartleadRunResponse(BaseModel):
frontend/src/components/workspace/EditableCell.jsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react';
2
+ import { cn } from '@/lib/utils';
3
+
4
+ const baseInput =
5
+ 'w-full min-w-0 rounded-md border border-transparent bg-transparent px-1.5 py-0.5 text-sm text-slate-800 ' +
6
+ 'hover:border-slate-200 focus:border-violet-300 focus:bg-white focus:outline-none focus:ring-1 focus:ring-violet-200';
7
+
8
+ /**
9
+ * Inline text / email / number field: blur or Enter commits if the value changed.
10
+ */
11
+ export function EditableCell({
12
+ value,
13
+ onCommit,
14
+ className = '',
15
+ inputClassName = '',
16
+ type = 'text',
17
+ multiline = false,
18
+ disabled = false,
19
+ }) {
20
+ const str = value == null || value === undefined ? '' : String(value);
21
+ const [local, setLocal] = useState(str);
22
+
23
+ useEffect(() => {
24
+ setLocal(str);
25
+ }, [str]);
26
+
27
+ const commit = () => {
28
+ if (disabled) return;
29
+ const next = multiline ? local : local.trim();
30
+ const prev = multiline ? str : str.trim();
31
+ if (next !== prev) onCommit(next);
32
+ };
33
+
34
+ const stop = (e) => e.stopPropagation();
35
+
36
+ if (multiline) {
37
+ return (
38
+ <textarea
39
+ className={cn(baseInput, 'resize-y min-h-[2.5rem] max-w-full', className, inputClassName)}
40
+ value={local}
41
+ onChange={(e) => setLocal(e.target.value)}
42
+ onBlur={commit}
43
+ onKeyDown={stop}
44
+ onClick={stop}
45
+ disabled={disabled}
46
+ rows={2}
47
+ />
48
+ );
49
+ }
50
+
51
+ return (
52
+ <input
53
+ type={type}
54
+ className={cn(baseInput, className, inputClassName)}
55
+ value={local}
56
+ onChange={(e) => setLocal(e.target.value)}
57
+ onBlur={commit}
58
+ onKeyDown={(e) => {
59
+ stop(e);
60
+ if (e.key === 'Enter') {
61
+ e.preventDefault();
62
+ e.currentTarget.blur();
63
+ }
64
+ }}
65
+ onClick={stop}
66
+ disabled={disabled}
67
+ />
68
+ );
69
+ }
70
+
71
+ function toDateInputValue(iso) {
72
+ if (!iso) return '';
73
+ const d = new Date(iso);
74
+ if (Number.isNaN(d.getTime())) return '';
75
+ return d.toISOString().slice(0, 10);
76
+ }
77
+
78
+ /**
79
+ * ISO datetime from API → date input; onCommit receives YYYY-MM-DD or empty string for clear.
80
+ */
81
+ export function EditableDateCell({ value, onCommit, className = '', disabled = false }) {
82
+ const [local, setLocal] = useState(() => toDateInputValue(value));
83
+
84
+ useEffect(() => {
85
+ setLocal(toDateInputValue(value));
86
+ }, [value]);
87
+
88
+ const commit = () => {
89
+ if (disabled) return;
90
+ const prev = toDateInputValue(value);
91
+ if (local !== prev) onCommit(local);
92
+ };
93
+
94
+ return (
95
+ <input
96
+ type="date"
97
+ className={cn(baseInput, 'max-w-[11rem]', className)}
98
+ value={local}
99
+ onChange={(e) => setLocal(e.target.value)}
100
+ onBlur={commit}
101
+ onKeyDown={(e) => e.stopPropagation()}
102
+ onClick={(e) => e.stopPropagation()}
103
+ disabled={disabled}
104
+ />
105
+ );
106
+ }
frontend/src/pages/Contacts.jsx CHANGED
@@ -24,6 +24,7 @@ import { Button } from '@/components/ui/button';
24
  import AppShell from '@/components/layout/AppShell';
25
  import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
26
  import SlideOverPanel from '@/components/workspace/SlideOverPanel';
 
27
  import { cn } from '@/lib/utils';
28
 
29
  export default function Contacts() {
@@ -117,6 +118,27 @@ export default function Contacts() {
117
  }
118
  };
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  const fetchContacts = async () => {
121
  setLoading(true);
122
  try {
@@ -741,17 +763,54 @@ export default function Contacts() {
741
  aria-label={`Select ${displayName}`}
742
  />
743
  </td>
744
- <td className="px-3 py-2 font-medium text-violet-800">
745
- {displayName}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
  </td>
747
- <td className="px-3 py-2 text-slate-700 truncate max-w-[220px]">
748
- {contact.email || '—'}
 
 
 
 
 
 
 
749
  </td>
750
- <td className="px-3 py-2 text-slate-700 truncate max-w-[200px]">
751
- {contact.company || '—'}
 
 
 
 
 
 
752
  </td>
753
- <td className="px-3 py-2 text-slate-600 truncate max-w-[180px]">
754
- {contact.title || '—'}
 
 
 
 
 
 
755
  </td>
756
  </tr>
757
  );
 
24
  import AppShell from '@/components/layout/AppShell';
25
  import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
26
  import SlideOverPanel from '@/components/workspace/SlideOverPanel';
27
+ import { EditableCell } from '@/components/workspace/EditableCell';
28
  import { cn } from '@/lib/utils';
29
 
30
  export default function Contacts() {
 
118
  }
119
  };
120
 
121
+ const patchContact = async (contactId, patch) => {
122
+ try {
123
+ const res = await fetch(`/api/contacts/${contactId}`, {
124
+ method: 'PATCH',
125
+ headers: { 'Content-Type': 'application/json' },
126
+ body: JSON.stringify(patch),
127
+ });
128
+ const data = await res.json().catch(() => ({}));
129
+ if (!res.ok) {
130
+ throw new Error(typeof data.detail === 'string' ? data.detail : 'Update failed');
131
+ }
132
+ setContacts((prev) => prev.map((c) => (c.id === contactId ? { ...c, ...data } : c)));
133
+ setSelectedContact((c) => (c && c.id === contactId ? { ...c, ...data } : c));
134
+ setSelectedContactDetails((d) => (d && d.id === contactId ? { ...d, ...data } : d));
135
+ } catch (e) {
136
+ console.error(e);
137
+ alert(e.message || 'Could not save changes');
138
+ await fetchContacts();
139
+ }
140
+ };
141
+
142
  const fetchContacts = async () => {
143
  setLoading(true);
144
  try {
 
763
  aria-label={`Select ${displayName}`}
764
  />
765
  </td>
766
+ <td
767
+ className="px-3 py-2 align-top"
768
+ onClick={(e) => e.stopPropagation()}
769
+ >
770
+ <div className="flex flex-col gap-1 min-w-[140px] max-w-[200px]">
771
+ <EditableCell
772
+ value={contact.first_name || ''}
773
+ onCommit={(v) =>
774
+ patchContact(contact.id, { first_name: v })
775
+ }
776
+ inputClassName="font-medium text-violet-800"
777
+ />
778
+ <EditableCell
779
+ value={contact.last_name || ''}
780
+ onCommit={(v) =>
781
+ patchContact(contact.id, { last_name: v })
782
+ }
783
+ inputClassName="font-medium text-violet-800"
784
+ />
785
+ </div>
786
  </td>
787
+ <td
788
+ className="px-3 py-2 align-top max-w-[240px]"
789
+ onClick={(e) => e.stopPropagation()}
790
+ >
791
+ <EditableCell
792
+ type="email"
793
+ value={contact.email || ''}
794
+ onCommit={(v) => patchContact(contact.id, { email: v })}
795
+ />
796
  </td>
797
+ <td
798
+ className="px-3 py-2 align-top max-w-[220px]"
799
+ onClick={(e) => e.stopPropagation()}
800
+ >
801
+ <EditableCell
802
+ value={contact.company || ''}
803
+ onCommit={(v) => patchContact(contact.id, { company: v })}
804
+ />
805
  </td>
806
+ <td
807
+ className="px-3 py-2 align-top max-w-[200px]"
808
+ onClick={(e) => e.stopPropagation()}
809
+ >
810
+ <EditableCell
811
+ value={contact.title || ''}
812
+ onCommit={(v) => patchContact(contact.id, { title: v })}
813
+ />
814
  </td>
815
  </tr>
816
  );
frontend/src/pages/Deals.jsx CHANGED
@@ -6,6 +6,7 @@ import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/u
6
  import AppShell from '@/components/layout/AppShell';
7
  import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
8
  import SlideOverPanel from '@/components/workspace/SlideOverPanel';
 
9
  import { cn } from '@/lib/utils';
10
 
11
  const STAGES = [
@@ -88,23 +89,28 @@ export default function Deals() {
88
  }
89
  };
90
 
91
- const updateStage = async (dealId, stage) => {
92
  try {
93
  const res = await fetch(`/api/deals/${dealId}`, {
94
  method: 'PATCH',
95
  headers: { 'Content-Type': 'application/json' },
96
- body: JSON.stringify({ stage }),
97
  });
98
- if (!res.ok) throw new Error('Update failed');
99
- const updated = await res.json();
100
- setDeals((prev) => prev.map((d) => (d.id === dealId ? { ...d, ...updated } : d)));
101
- setDealDetail((cur) => (cur && cur.id === dealId ? { ...cur, ...updated } : cur));
 
 
102
  } catch (e) {
103
  console.error(e);
104
- alert('Could not update stage');
 
105
  }
106
  };
107
 
 
 
108
  const openDeal = async (deal) => {
109
  setPanelOpen(true);
110
  setDealDetail(deal);
@@ -210,7 +216,16 @@ export default function Deals() {
210
  <td className="px-2 py-2" onClick={(e) => e.stopPropagation()}>
211
  <input type="checkbox" className="rounded border-slate-300" />
212
  </td>
213
- <td className="px-3 py-2 font-medium text-slate-900">{deal.name}</td>
 
 
 
 
 
 
 
 
 
214
  <td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
215
  <Select
216
  value={safeStage}
@@ -246,45 +261,112 @@ export default function Deals() {
246
  </SelectContent>
247
  </Select>
248
  </td>
249
- <td className="px-3 py-2">
250
- <div className="h-8 w-8 rounded-full bg-violet-100 text-violet-700 text-xs font-semibold flex items-center justify-center border border-violet-200">
251
- {deal.owner_initials || '?'}
252
- </div>
 
 
 
 
 
 
253
  </td>
254
- <td className="px-3 py-2 text-slate-700 tabular-nums">
255
- {fmtMoney(deal.deal_value)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  </td>
257
- <td className="px-3 py-2">
258
- {deal.contact_display ? (
259
- <span className="inline-flex rounded-md bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-800">
260
- {deal.contact_display}
261
- </span>
262
- ) : (
263
- '—'
264
- )}
 
265
  </td>
266
- <td className="px-3 py-2">
267
- {deal.account_name ? (
268
- <span className="inline-flex rounded-md bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-800">
269
- {deal.account_name}
270
- </span>
271
- ) : (
272
- '—'
273
- )}
 
274
  </td>
275
- <td className="px-3 py-2 text-slate-600">
276
- {fmtDate(deal.expected_close_date)}
 
 
 
 
 
 
 
277
  </td>
278
- <td className="px-3 py-2 tabular-nums text-slate-700">
279
- {deal.close_probability ?? '—'}%
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  </td>
281
- <td className="px-3 py-2 tabular-nums text-slate-700">
 
 
 
282
  {fmtMoney(deal.forecast_value)}
283
  </td>
284
- <td className="px-3 py-2 text-slate-600">
285
- {fmtDate(deal.last_interaction_at)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  </td>
287
- <td className="px-3 py-2 text-slate-700">{deal.country || '—'}</td>
288
  </tr>
289
  );
290
  })}
 
6
  import AppShell from '@/components/layout/AppShell';
7
  import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
8
  import SlideOverPanel from '@/components/workspace/SlideOverPanel';
9
+ import { EditableCell, EditableDateCell } from '@/components/workspace/EditableCell';
10
  import { cn } from '@/lib/utils';
11
 
12
  const STAGES = [
 
89
  }
90
  };
91
 
92
+ const patchDeal = async (dealId, patch) => {
93
  try {
94
  const res = await fetch(`/api/deals/${dealId}`, {
95
  method: 'PATCH',
96
  headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify(patch),
98
  });
99
+ const data = await res.json().catch(() => ({}));
100
+ if (!res.ok) {
101
+ throw new Error(typeof data.detail === 'string' ? data.detail : 'Update failed');
102
+ }
103
+ setDeals((prev) => prev.map((d) => (d.id === dealId ? { ...d, ...data } : d)));
104
+ setDealDetail((cur) => (cur && cur.id === dealId ? { ...cur, ...data } : cur));
105
  } catch (e) {
106
  console.error(e);
107
+ alert(e.message || 'Could not save deal');
108
+ await fetchDeals();
109
  }
110
  };
111
 
112
+ const updateStage = (dealId, stage) => patchDeal(dealId, { stage });
113
+
114
  const openDeal = async (deal) => {
115
  setPanelOpen(true);
116
  setDealDetail(deal);
 
216
  <td className="px-2 py-2" onClick={(e) => e.stopPropagation()}>
217
  <input type="checkbox" className="rounded border-slate-300" />
218
  </td>
219
+ <td
220
+ className="px-3 py-2 align-top max-w-[220px] font-medium"
221
+ onClick={(e) => e.stopPropagation()}
222
+ >
223
+ <EditableCell
224
+ value={deal.name || ''}
225
+ onCommit={(v) => patchDeal(deal.id, { name: v })}
226
+ inputClassName="font-medium text-slate-900"
227
+ />
228
+ </td>
229
  <td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
230
  <Select
231
  value={safeStage}
 
261
  </SelectContent>
262
  </Select>
263
  </td>
264
+ <td className="px-3 py-2 align-top" onClick={(e) => e.stopPropagation()}>
265
+ <EditableCell
266
+ value={deal.owner_initials || ''}
267
+ onCommit={(v) =>
268
+ patchDeal(deal.id, {
269
+ owner_initials: (v || '').slice(0, 8).toUpperCase(),
270
+ })
271
+ }
272
+ inputClassName="text-center uppercase tracking-wide max-w-[4.5rem] font-semibold"
273
+ />
274
  </td>
275
+ <td
276
+ className="px-3 py-2 align-top tabular-nums max-w-[120px]"
277
+ onClick={(e) => e.stopPropagation()}
278
+ >
279
+ <EditableCell
280
+ type="number"
281
+ value={deal.deal_value != null ? String(deal.deal_value) : ''}
282
+ onCommit={(v) => {
283
+ if (v.trim() === '') {
284
+ patchDeal(deal.id, { deal_value: null });
285
+ return;
286
+ }
287
+ const n = Math.round(Number(v));
288
+ if (!Number.isFinite(n)) return;
289
+ patchDeal(deal.id, { deal_value: n });
290
+ }}
291
+ />
292
  </td>
293
+ <td
294
+ className="px-3 py-2 align-top max-w-[180px]"
295
+ onClick={(e) => e.stopPropagation()}
296
+ >
297
+ <EditableCell
298
+ value={deal.contact_display || ''}
299
+ onCommit={(v) => patchDeal(deal.id, { contact_display: v })}
300
+ inputClassName="text-xs"
301
+ />
302
  </td>
303
+ <td
304
+ className="px-3 py-2 align-top max-w-[200px]"
305
+ onClick={(e) => e.stopPropagation()}
306
+ >
307
+ <EditableCell
308
+ value={deal.account_name || ''}
309
+ onCommit={(v) => patchDeal(deal.id, { account_name: v })}
310
+ inputClassName="text-xs"
311
+ />
312
  </td>
313
+ <td className="px-3 py-2 align-top" onClick={(e) => e.stopPropagation()}>
314
+ <EditableDateCell
315
+ value={deal.expected_close_date}
316
+ onCommit={(dateStr) =>
317
+ patchDeal(deal.id, {
318
+ expected_close_date: dateStr || null,
319
+ })
320
+ }
321
+ />
322
  </td>
323
+ <td
324
+ className="px-3 py-2 align-top tabular-nums max-w-[90px]"
325
+ onClick={(e) => e.stopPropagation()}
326
+ >
327
+ <EditableCell
328
+ type="number"
329
+ value={
330
+ deal.close_probability != null
331
+ ? String(deal.close_probability)
332
+ : ''
333
+ }
334
+ onCommit={(v) => {
335
+ if (v.trim() === '') {
336
+ patchDeal(deal.id, { close_probability: 0 });
337
+ return;
338
+ }
339
+ const n = Math.min(100, Math.max(0, Math.round(Number(v))));
340
+ if (!Number.isFinite(n)) return;
341
+ patchDeal(deal.id, { close_probability: n });
342
+ }}
343
+ />
344
  </td>
345
+ <td
346
+ className="px-3 py-2 tabular-nums text-slate-700"
347
+ title="Forecast = deal value × close %"
348
+ >
349
  {fmtMoney(deal.forecast_value)}
350
  </td>
351
+ <td className="px-3 py-2 align-top" onClick={(e) => e.stopPropagation()}>
352
+ <EditableDateCell
353
+ value={deal.last_interaction_at}
354
+ onCommit={(dateStr) =>
355
+ patchDeal(deal.id, {
356
+ last_interaction_at: dateStr || null,
357
+ })
358
+ }
359
+ />
360
+ </td>
361
+ <td
362
+ className="px-3 py-2 align-top max-w-[120px]"
363
+ onClick={(e) => e.stopPropagation()}
364
+ >
365
+ <EditableCell
366
+ value={deal.country || ''}
367
+ onCommit={(v) => patchDeal(deal.id, { country: v })}
368
+ />
369
  </td>
 
370
  </tr>
371
  );
372
  })}
frontend/src/pages/Leads.jsx CHANGED
@@ -16,6 +16,7 @@ import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/u
16
  import AppShell from '@/components/layout/AppShell';
17
  import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
18
  import SlideOverPanel from '@/components/workspace/SlideOverPanel';
 
19
  import { cn } from '@/lib/utils';
20
 
21
  const CRM_STATUSES = [
@@ -110,23 +111,28 @@ export default function Leads() {
110
  return () => clearTimeout(t);
111
  }, [fetchLeads]);
112
 
113
- const updateStatus = async (leadId, crmStatus) => {
114
  try {
115
  const res = await fetch(`/api/leads/${leadId}`, {
116
  method: 'PATCH',
117
  headers: { 'Content-Type': 'application/json' },
118
- body: JSON.stringify({ crm_status: crmStatus }),
119
  });
120
- if (!res.ok) throw new Error(await res.text());
121
- const updated = await res.json();
122
- setLeads((prev) => prev.map((l) => (l.id === leadId ? { ...l, ...updated } : l)));
123
- setSelected((s) => (s && s.id === leadId ? { ...s, ...updated } : s));
 
 
124
  } catch (e) {
125
  console.error(e);
126
- alert('Could not update status');
 
127
  }
128
  };
129
 
 
 
130
  const loadThread = async (leadId) => {
131
  setThreadLoading(true);
132
  setThreadData(null);
@@ -440,10 +446,24 @@ export default function Leads() {
440
  aria-label={`Select ${displayName}`}
441
  />
442
  </td>
443
- <td className="px-3 py-2">
444
- <span className="font-medium text-violet-800">{displayName}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  {lead.contact_id ? (
446
- <span className="ml-2 text-xs text-emerald-600">
447
  (in Contacts)
448
  </span>
449
  ) : null}
@@ -487,39 +507,60 @@ export default function Leads() {
487
  </SelectContent>
488
  </Select>
489
  </td>
490
- <td className="px-3 py-2 text-slate-700">
491
- <span className="inline-flex items-center gap-1">
492
- <Building2 className="h-3.5 w-3.5 text-slate-400" />
493
- {lead.company_name || '—'}
 
 
 
 
 
 
494
  </span>
495
  </td>
496
- <td className="px-3 py-2 text-slate-700">
497
- <span className="inline-flex items-center gap-1">
498
- <Briefcase className="h-3.5 w-3.5 text-slate-400" />
499
- {lead.title || '—'}
 
 
 
 
 
 
500
  </span>
501
  </td>
502
- <td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
503
- {lead.email ? (
504
- <a
505
- href={`mailto:${lead.email}`}
506
- className="text-violet-600 hover:underline inline-flex items-center gap-1"
507
- >
508
- <Mail className="h-3.5 w-3.5" />
509
- {lead.email}
510
- </a>
511
- ) : (
512
- '—'
513
- )}
 
 
 
 
 
 
 
514
  </td>
515
  <td
516
- className="px-3 py-2 text-slate-600 max-w-[200px] truncate"
517
- title={lead.last_reply_body}
518
  >
519
- {lead.last_reply_body
520
- ? lead.last_reply_body.slice(0, 80) +
521
- (lead.last_reply_body.length > 80 ? '' : '')
522
- : '—'}
 
 
523
  </td>
524
  </tr>
525
  );
 
16
  import AppShell from '@/components/layout/AppShell';
17
  import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
18
  import SlideOverPanel from '@/components/workspace/SlideOverPanel';
19
+ import { EditableCell } from '@/components/workspace/EditableCell';
20
  import { cn } from '@/lib/utils';
21
 
22
  const CRM_STATUSES = [
 
111
  return () => clearTimeout(t);
112
  }, [fetchLeads]);
113
 
114
+ const patchLead = async (leadId, patch) => {
115
  try {
116
  const res = await fetch(`/api/leads/${leadId}`, {
117
  method: 'PATCH',
118
  headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify(patch),
120
  });
121
+ const data = await res.json().catch(() => ({}));
122
+ if (!res.ok) {
123
+ throw new Error(typeof data.detail === 'string' ? data.detail : 'Update failed');
124
+ }
125
+ setLeads((prev) => prev.map((l) => (l.id === leadId ? { ...l, ...data } : l)));
126
+ setSelected((s) => (s && s.id === leadId ? { ...s, ...data } : s));
127
  } catch (e) {
128
  console.error(e);
129
+ alert(e.message || 'Could not update lead');
130
+ await fetchLeads();
131
  }
132
  };
133
 
134
+ const updateStatus = (leadId, crmStatus) => patchLead(leadId, { crm_status: crmStatus });
135
+
136
  const loadThread = async (leadId) => {
137
  setThreadLoading(true);
138
  setThreadData(null);
 
446
  aria-label={`Select ${displayName}`}
447
  />
448
  </td>
449
+ <td
450
+ className="px-3 py-2 align-top max-w-[200px]"
451
+ onClick={(e) => e.stopPropagation()}
452
+ >
453
+ <div className="flex flex-col gap-1">
454
+ <EditableCell
455
+ value={lead.first_name || ''}
456
+ onCommit={(v) => patchLead(lead.id, { first_name: v })}
457
+ inputClassName="font-medium text-violet-800"
458
+ />
459
+ <EditableCell
460
+ value={lead.last_name || ''}
461
+ onCommit={(v) => patchLead(lead.id, { last_name: v })}
462
+ inputClassName="font-medium text-violet-800"
463
+ />
464
+ </div>
465
  {lead.contact_id ? (
466
+ <span className="mt-1 text-xs text-emerald-600">
467
  (in Contacts)
468
  </span>
469
  ) : null}
 
507
  </SelectContent>
508
  </Select>
509
  </td>
510
+ <td
511
+ className="px-3 py-2 align-top max-w-[220px]"
512
+ onClick={(e) => e.stopPropagation()}
513
+ >
514
+ <span className="inline-flex items-start gap-1.5 w-full">
515
+ <Building2 className="h-3.5 w-3.5 text-slate-400 shrink-0 mt-1" />
516
+ <EditableCell
517
+ value={lead.company_name || ''}
518
+ onCommit={(v) => patchLead(lead.id, { company_name: v })}
519
+ />
520
  </span>
521
  </td>
522
+ <td
523
+ className="px-3 py-2 align-top max-w-[200px]"
524
+ onClick={(e) => e.stopPropagation()}
525
+ >
526
+ <span className="inline-flex items-start gap-1.5 w-full">
527
+ <Briefcase className="h-3.5 w-3.5 text-slate-400 shrink-0 mt-1" />
528
+ <EditableCell
529
+ value={lead.title || ''}
530
+ onCommit={(v) => patchLead(lead.id, { title: v })}
531
+ />
532
  </span>
533
  </td>
534
+ <td className="px-3 py-2 align-top max-w-[260px]" onClick={(e) => e.stopPropagation()}>
535
+ <div className="flex items-start gap-1.5">
536
+ <EditableCell
537
+ type="email"
538
+ value={lead.email || ''}
539
+ onCommit={(v) => patchLead(lead.id, { email: v })}
540
+ className="min-w-0 flex-1"
541
+ />
542
+ {lead.email ? (
543
+ <a
544
+ href={`mailto:${lead.email}`}
545
+ className="mt-1 shrink-0 text-violet-600 hover:text-violet-800"
546
+ title="Send email"
547
+ onClick={(e) => e.stopPropagation()}
548
+ >
549
+ <Mail className="h-3.5 w-3.5" />
550
+ </a>
551
+ ) : null}
552
+ </div>
553
  </td>
554
  <td
555
+ className="px-3 py-2 align-top text-slate-600 max-w-[280px]"
556
+ onClick={(e) => e.stopPropagation()}
557
  >
558
+ <EditableCell
559
+ multiline
560
+ value={lead.last_reply_body || ''}
561
+ onCommit={(v) => patchLead(lead.id, { last_reply_body: v })}
562
+ inputClassName="text-xs"
563
+ />
564
  </td>
565
  </tr>
566
  );