Seth Claude Sonnet 5 commited on
Commit ·
1d7a01a
1
Parent(s): 67330bb
Add "Add a contact" form to Leads and Deals detail panels
Browse filesMirrors 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 |
-
|
| 1315 |
-
const name = (
|
| 1316 |
if (!name) {
|
| 1317 |
setAssociatedContacts([]);
|
| 1318 |
return;
|
| 1319 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1320 |
let cancelled = false;
|
| 1321 |
(async () => {
|
| 1322 |
-
|
| 1323 |
-
|
| 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 |
-
|
| 263 |
-
const name = (
|
| 264 |
if (!name) {
|
| 265 |
setAssociatedContacts([]);
|
| 266 |
return;
|
| 267 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
let cancelled = false;
|
| 269 |
(async () => {
|
| 270 |
-
|
| 271 |
-
|
| 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}
|