Retire contacts.contact_type; derive Contacts status from the grid (v0.1.0:106)

The Investors/Prospects distinction is now derived live from the canonical
grid (contact_grid_signals -> committed/pipeline_stage), not the mechanically
set contact_type column:

- Desktop Contacts: drop the Investors/Prospects tabs + TYPE badge; show a
  derived Status (existing-LP badge + pipeline stage chip).
- Dashboard: repoint Total LPs / Prospects onto fundraising_investors entities
  (committed>0 vs $0, graveyard + blank-row placeholder excluded); fix a
  total_contacts soft-delete leak.
- Stop reading/writing contact_type across the create/update/import/sync paths.
  The column is left inert in place; a physical drop is deferred to a later
  signed-off table-rebuild migration (SQLite no-drop-column; contacts is
  FK-referenced) -- same retire-then-drop path lp_profiles took.
This commit is contained in:
Keysat
2026-06-20 22:09:02 -05:00
parent b23c48bf7a
commit 05f15b9197
6 changed files with 105 additions and 81 deletions
+24 -52
View File
@@ -4373,6 +4373,22 @@
);
};
// Derived contact status from the live grid signals the API injects (committed / pipeline_stage) —
// replaces the retired contact_type. An existing LP (committed > 0) shows an LP badge; an
// in-pipeline contact shows its stage chip (both can show at once — a committed LP still in an
// active deal); a contact with neither is a Prospect. Used by the desktop Contacts list + detail.
const renderContactStatus = (c) => {
const existing = Number((c && c.committed) || 0) > 0;
const stage = c && c.pipeline_stage;
if (!existing && !stage) return <span className="badge badge-prospect">Prospect</span>;
return (
<span style={{ display: 'inline-flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' }}>
{existing && <span className="badge badge-investor">LP</span>}
{stage && <StageChip stage={stage} sm />}
</span>
);
};
// Existing-LP earmark — a quiet accent corner-triangle (top-left), the locked existing-investor
// signal on cards (dc GridApp:89-91). Reusable: the Grid card now, the Pipeline card (8f). Render
// only when the investor has committed capital (existing_investor / committed > 0).
@@ -5507,7 +5523,6 @@
const [contactsPage, setContactsPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [tab, setTab] = useState('all');
const [search, setSearch] = useState('');
const [sort, setSort] = useState('last_name');
const [order, setOrder] = useState('asc');
@@ -5515,19 +5530,18 @@
const [deleting, setDeleting] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const tabFilter = tab === 'all' ? '' : tab === 'investors' ? 'investor' : 'prospect';
const contactsOffset = (contactsPage - 1) * CONTACTS_PAGE_SIZE;
const contactsMaxPage = Math.max(1, Math.ceil((Number(contactsTotal) || 0) / CONTACTS_PAGE_SIZE));
useEffect(() => {
setContactsPage(1);
}, [tab, search, sort, order]);
}, [search, sort, order]);
useEffect(() => {
const fetchContacts = async () => {
try {
setLoading(true);
const result = await api(`/api/contacts?search=${encodeURIComponent(search)}&type=${tabFilter}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
const result = await api(`/api/contacts?search=${encodeURIComponent(search)}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
setContacts(result.data || []);
setContactsTotal(Number(result.total) || 0);
} catch (err) {
@@ -5538,7 +5552,7 @@
};
fetchContacts();
}, [token, tab, search, sort, order, contactsOffset]);
}, [token, search, sort, order, contactsOffset]);
const handleDeleteContact = async (id) => {
setDeleting(id);
@@ -5555,16 +5569,6 @@
}
};
const contactTypeTooltip = (type) => {
const badges = {
'investor': 'badge-investor',
'prospect': 'badge-prospect',
'advisor': 'badge-advisor',
'other': 'badge-other'
};
return badges[type] || 'badge-other';
};
const handleSort = (column) => {
if (sort === column) {
setOrder(order === 'asc' ? 'desc' : 'asc');
@@ -5579,18 +5583,6 @@
<h2 className="section-title">Contacts</h2>
<div className="section">
<div className="tabs">
{['all', 'investors', 'prospects'].map(t => (
<button
key={t}
className={`tab ${tab === t ? 'active' : ''}`}
onClick={() => setTab(t)}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
<div className="controls">
<input
type="text"
@@ -5617,7 +5609,7 @@
<th onClick={() => handleSort('source')} style={{ cursor: 'pointer' }}>
Lead Source {sort === 'source' && (order === 'asc' ? '▲' : '▼')}
</th>
<th>Type</th>
<th>Status</th>
<th>Email</th>
<th>Last Contact</th>
<th>Communications</th>
@@ -5629,11 +5621,7 @@
<td>{contact.first_name} {contact.last_name}</td>
<td>{contact.organization || contact.organization_name || '-'}</td>
<td>{contact.source || '-'}</td>
<td>
<span className={`badge ${contactTypeTooltip(contact.contact_type)}`}>
{contact.contact_type}
</span>
</td>
<td>{renderContactStatus(contact)}</td>
<td>{contact.email || '-'}</td>
<td>{formatDate(contact.last_contact_date)}</td>
<td>{contact.communication_count ?? contact.comm_count ?? 0}</td>
@@ -5677,7 +5665,7 @@
onDelete={() => setConfirmDelete(selectedContact.id)}
token={token}
onRefresh={() => {
const result = api(`/api/contacts?search=${encodeURIComponent(search)}&type=${tabFilter}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
const result = api(`/api/contacts?search=${encodeURIComponent(search)}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
result.then(r => {
setContacts(r.data || []);
setContactsTotal(Number(r.total) || 0);
@@ -6017,7 +6005,6 @@
city: '',
state: '',
country: '',
contact_type: 'prospect',
status: 'active'
});
@@ -6050,7 +6037,6 @@
city: details.city || '',
state: details.state || '',
country: details.country || '',
contact_type: details.contact_type || 'prospect',
status: details.status || 'active'
});
}, [details]);
@@ -6076,7 +6062,6 @@
city: editDraft.city.trim(),
state: editDraft.state.trim(),
country: editDraft.country.trim(),
contact_type: editDraft.contact_type,
status: editDraft.status
})
}, token);
@@ -6156,15 +6141,6 @@
<label className="form-label">Country</label>
<input className="text-input" value={editDraft.country} onChange={(e) => setEditDraft((d) => ({ ...d, country: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Type</label>
<select className="select-input" value={editDraft.contact_type} onChange={(e) => setEditDraft((d) => ({ ...d, contact_type: e.target.value }))}>
<option value="investor">Investor</option>
<option value="prospect">Prospect</option>
<option value="advisor">Advisor</option>
<option value="other">Other</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Status</label>
<select className="select-input" value={editDraft.status} onChange={(e) => setEditDraft((d) => ({ ...d, status: e.target.value }))}>
@@ -6207,12 +6183,8 @@
</span>
</div>
<div className="detail-row">
<span className="detail-label">Type</span>
<span className="detail-value">
<span className={`badge ${details.contact_type === 'investor' ? 'badge-investor' : 'badge-prospect'}`}>
{details.contact_type}
</span>
</span>
<span className="detail-label">Status</span>
<span className="detail-value">{renderContactStatus(details)}</span>
</div>
</>
)}