Seth Claude Sonnet 5 commited on
Commit
1d7a01a
·
1 Parent(s): 67330bb

Add "Add a contact" form to Leads and Deals detail panels

Browse files

Mirrors the Companies page's existing "Add a contact" box via a new shared
AddContactForm component, using the generic POST /api/contacts route (rather
than the Companies-specific one) so it works even before a Lead/Deal's
company has synced into a Company row. Refreshes the associated-contacts
list automatically after a save.

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

frontend/src/components/workspace/AddContactForm.jsx ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Loader2 } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import { apiFetch } from '@/lib/api';
6
+
7
+ const EMPTY_DRAFT = { first_name: '', last_name: '', email: '', title: '', phone: '' };
8
+
9
+ /**
10
+ * "Add a contact" mini-form for the associated-contacts section of a company-scoped detail
11
+ * panel (Companies / Leads / Deals). Creates the contact via the generic POST /api/contacts
12
+ * route with `company` locked to `companyName` — unlike the Companies page's own
13
+ * /api/companies/{id}/contacts route, this doesn't require an existing Company row, so it
14
+ * works even when the lead/deal's company hasn't synced into one yet.
15
+ */
16
+ export default function AddContactForm({ companyName, onAdded, className }) {
17
+ const [draft, setDraft] = useState(EMPTY_DRAFT);
18
+ const [saving, setSaving] = useState(false);
19
+ const [error, setError] = useState('');
20
+
21
+ const setField = (key, value) => setDraft((d) => ({ ...d, [key]: value }));
22
+
23
+ const save = async () => {
24
+ const email = draft.email.trim();
25
+ if (!email) {
26
+ setError('Email is required');
27
+ return;
28
+ }
29
+ if (!email.includes('@')) {
30
+ setError('Enter a valid email address');
31
+ return;
32
+ }
33
+ setSaving(true);
34
+ setError('');
35
+ try {
36
+ const res = await apiFetch('/api/contacts', {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({
40
+ first_name: draft.first_name.trim(),
41
+ last_name: draft.last_name.trim(),
42
+ email,
43
+ title: draft.title.trim(),
44
+ phone: draft.phone.trim(),
45
+ company: companyName,
46
+ }),
47
+ });
48
+ const data = await res.json().catch(() => ({}));
49
+ if (!res.ok) {
50
+ setError(typeof data.detail === 'string' ? data.detail : 'Could not save contact');
51
+ return;
52
+ }
53
+ setDraft(EMPTY_DRAFT);
54
+ onAdded && onAdded(data);
55
+ } catch (e) {
56
+ console.error(e);
57
+ setError('Network error');
58
+ } finally {
59
+ setSaving(false);
60
+ }
61
+ };
62
+
63
+ return (
64
+ <div className={className || 'rounded-xl border border-slate-200 bg-slate-50/50 p-4'}>
65
+ <h5 className="mb-3 text-sm font-semibold text-slate-700">Add a contact</h5>
66
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
67
+ <Input
68
+ className="bg-white text-sm"
69
+ placeholder="First name"
70
+ value={draft.first_name}
71
+ onChange={(e) => setField('first_name', e.target.value)}
72
+ disabled={saving}
73
+ />
74
+ <Input
75
+ className="bg-white text-sm"
76
+ placeholder="Last name"
77
+ value={draft.last_name}
78
+ onChange={(e) => setField('last_name', e.target.value)}
79
+ disabled={saving}
80
+ />
81
+ <Input
82
+ className="bg-white text-sm sm:col-span-2"
83
+ type="email"
84
+ placeholder="Email"
85
+ value={draft.email}
86
+ onChange={(e) => setField('email', e.target.value)}
87
+ disabled={saving}
88
+ />
89
+ <Input
90
+ className="bg-white text-sm"
91
+ placeholder="Title"
92
+ value={draft.title}
93
+ onChange={(e) => setField('title', e.target.value)}
94
+ disabled={saving}
95
+ />
96
+ <Input
97
+ className="bg-white text-sm"
98
+ type="tel"
99
+ placeholder="Contact number"
100
+ value={draft.phone}
101
+ onChange={(e) => setField('phone', e.target.value)}
102
+ disabled={saving}
103
+ />
104
+ </div>
105
+ {error ? <p className="mt-2 text-xs text-red-600">{error}</p> : null}
106
+ <Button type="button" className="mt-3 w-full" onClick={save} disabled={saving}>
107
+ {saving ? (
108
+ <>
109
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
110
+ Saving…
111
+ </>
112
+ ) : (
113
+ 'Add contact'
114
+ )}
115
+ </Button>
116
+ </div>
117
+ );
118
+ }
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 ActivityTimeline from '@/components/workspace/ActivityTimeline';
15
  import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
16
  import { DealContactSearch, DealCompanySearch } from '@/components/workspace/DealLinkSearch';
@@ -1311,27 +1312,32 @@ export default function Deals() {
1311
  setAssociatedContacts([]);
1312
  };
1313
 
1314
- useEffect(() => {
1315
- const name = (dealDetail?.account_name || '').trim();
1316
  if (!name) {
1317
  setAssociatedContacts([]);
1318
  return;
1319
  }
 
 
 
 
 
 
 
 
 
 
 
1320
  let cancelled = false;
1321
  (async () => {
1322
- try {
1323
- const res = await apiFetch(`/api/contacts/by-company?company=${encodeURIComponent(name)}`);
1324
- if (!res.ok) return;
1325
- const data = await res.json();
1326
- if (!cancelled) setAssociatedContacts(data.contacts || []);
1327
- } catch (e) {
1328
- console.error(e);
1329
- }
1330
  })();
1331
  return () => {
1332
  cancelled = true;
1333
  };
1334
- }, [dealDetail?.account_name]);
1335
 
1336
  const bulkDeleteDeals = async () => {
1337
  if (selectedDealIds.length === 0) return;
@@ -2168,6 +2174,11 @@ export default function Deals() {
2168
  contacts={associatedContacts}
2169
  onContactClick={(c) => navigate(`/contacts?contact=${c.id}`)}
2170
  onActivityLogged={() => setActivityRefreshKey((k) => k + 1)}
 
 
 
 
 
2171
  />
2172
  </section>
2173
  ) : null}
 
11
  import WonBillingModal from '@/components/workspace/WonBillingModal';
12
  import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
13
  import AssociatedContactsList from '@/components/workspace/AssociatedContactsList';
14
+ import AddContactForm from '@/components/workspace/AddContactForm';
15
  import ActivityTimeline from '@/components/workspace/ActivityTimeline';
16
  import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
17
  import { DealContactSearch, DealCompanySearch } from '@/components/workspace/DealLinkSearch';
 
1312
  setAssociatedContacts([]);
1313
  };
1314
 
1315
+ const refreshAssociatedContacts = useCallback(async (companyName) => {
1316
+ const name = (companyName || '').trim();
1317
  if (!name) {
1318
  setAssociatedContacts([]);
1319
  return;
1320
  }
1321
+ try {
1322
+ const res = await apiFetch(`/api/contacts/by-company?company=${encodeURIComponent(name)}`);
1323
+ if (!res.ok) return;
1324
+ const data = await res.json();
1325
+ setAssociatedContacts(data.contacts || []);
1326
+ } catch (e) {
1327
+ console.error(e);
1328
+ }
1329
+ }, []);
1330
+
1331
+ useEffect(() => {
1332
  let cancelled = false;
1333
  (async () => {
1334
+ if (cancelled) return;
1335
+ await refreshAssociatedContacts(dealDetail?.account_name);
 
 
 
 
 
 
1336
  })();
1337
  return () => {
1338
  cancelled = true;
1339
  };
1340
+ }, [dealDetail?.account_name, refreshAssociatedContacts]);
1341
 
1342
  const bulkDeleteDeals = async () => {
1343
  if (selectedDealIds.length === 0) return;
 
2174
  contacts={associatedContacts}
2175
  onContactClick={(c) => navigate(`/contacts?contact=${c.id}`)}
2176
  onActivityLogged={() => setActivityRefreshKey((k) => k + 1)}
2177
+ className="mb-4"
2178
+ />
2179
+ <AddContactForm
2180
+ companyName={dealDetail.account_name}
2181
+ onAdded={() => refreshAssociatedContacts(dealDetail.account_name)}
2182
  />
2183
  </section>
2184
  ) : 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 ActivityTimeline from '@/components/workspace/ActivityTimeline';
24
  import { EditableCell } from '@/components/workspace/EditableCell';
25
  import { cn } from '@/lib/utils';
@@ -259,27 +260,32 @@ export default function Leads() {
259
  setAssociatedContacts([]);
260
  };
261
 
262
- useEffect(() => {
263
- const name = (selected?.company_name || '').trim();
264
  if (!name) {
265
  setAssociatedContacts([]);
266
  return;
267
  }
 
 
 
 
 
 
 
 
 
 
 
268
  let cancelled = false;
269
  (async () => {
270
- try {
271
- const res = await apiFetch(`/api/contacts/by-company?company=${encodeURIComponent(name)}`);
272
- if (!res.ok) return;
273
- const data = await res.json();
274
- if (!cancelled) setAssociatedContacts(data.contacts || []);
275
- } catch (e) {
276
- console.error(e);
277
- }
278
  })();
279
  return () => {
280
  cancelled = true;
281
  };
282
- }, [selected?.company_name]);
283
 
284
  const toggleRow = (id) => {
285
  setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }));
@@ -1035,6 +1041,11 @@ export default function Leads() {
1035
  contacts={associatedContacts}
1036
  onContactClick={(c) => navigate(`/contacts?contact=${c.id}`)}
1037
  onActivityLogged={() => setActivityRefreshKey((k) => k + 1)}
 
 
 
 
 
1038
  />
1039
  </div>
1040
  ) : null}
 
20
  import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
21
  import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
22
  import AssociatedContactsList from '@/components/workspace/AssociatedContactsList';
23
+ import AddContactForm from '@/components/workspace/AddContactForm';
24
  import ActivityTimeline from '@/components/workspace/ActivityTimeline';
25
  import { EditableCell } from '@/components/workspace/EditableCell';
26
  import { cn } from '@/lib/utils';
 
260
  setAssociatedContacts([]);
261
  };
262
 
263
+ const refreshAssociatedContacts = useCallback(async (companyName) => {
264
+ const name = (companyName || '').trim();
265
  if (!name) {
266
  setAssociatedContacts([]);
267
  return;
268
  }
269
+ try {
270
+ const res = await apiFetch(`/api/contacts/by-company?company=${encodeURIComponent(name)}`);
271
+ if (!res.ok) return;
272
+ const data = await res.json();
273
+ setAssociatedContacts(data.contacts || []);
274
+ } catch (e) {
275
+ console.error(e);
276
+ }
277
+ }, []);
278
+
279
+ useEffect(() => {
280
  let cancelled = false;
281
  (async () => {
282
+ if (cancelled) return;
283
+ await refreshAssociatedContacts(selected?.company_name);
 
 
 
 
 
 
284
  })();
285
  return () => {
286
  cancelled = true;
287
  };
288
+ }, [selected?.company_name, refreshAssociatedContacts]);
289
 
290
  const toggleRow = (id) => {
291
  setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }));
 
1041
  contacts={associatedContacts}
1042
  onContactClick={(c) => navigate(`/contacts?contact=${c.id}`)}
1043
  onActivityLogged={() => setActivityRefreshKey((k) => k + 1)}
1044
+ className="mb-4"
1045
+ />
1046
+ <AddContactForm
1047
+ companyName={selected.company_name}
1048
+ onAdded={() => refreshAssociatedContacts(selected.company_name)}
1049
  />
1050
  </div>
1051
  ) : null}