diff --git a/backend/server.py b/backend/server.py index b199a0f..c05d3d2 100644 --- a/backend/server.py +++ b/backend/server.py @@ -161,6 +161,11 @@ def init_db(): mobile TEXT, title TEXT, organization_id TEXT REFERENCES organizations(id) ON DELETE SET NULL, + -- RETIRED (v0.1.0:106), inert: the Investors/Prospects distinction is now derived live + -- from the grid (contact_grid_signals → committed/pipeline_stage), not this column. No + -- code reads or writes it; the DEFAULT keeps NOT NULL satisfied for inserts that omit it. + -- Physical drop deferred to a signed-off table-rebuild migration (SQLite has no DROP COLUMN + -- here; contacts is FK-referenced) — same retire-then-drop path lp_profiles took (v78→v104). contact_type TEXT NOT NULL DEFAULT 'prospect', status TEXT NOT NULL DEFAULT 'active', source TEXT, @@ -840,7 +845,7 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id conn.execute(""" UPDATE contacts SET first_name = ?, last_name = ?, email = ?, title = ?, - organization_id = ?, source = ?, contact_type = 'investor', city = ?, state = ?, country = ?, location_query = ?, linkedin_url = ?, phone = ?, mobile = ?, updated_at = ? + organization_id = ?, source = ?, city = ?, state = ?, country = ?, location_query = ?, linkedin_url = ?, phone = ?, mobile = ?, updated_at = ? WHERE id = ? """, (next_first, next_last, next_email, next_title, next_org, next_source, next_city, next_state, next_country, next_location_query, next_linkedin, next_phone, next_mobile, now(), existing['id'])) return existing['id'] @@ -848,8 +853,8 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id contact_id = generate_id() conn.execute(""" INSERT INTO contacts ( - id, first_name, last_name, email, title, organization_id, source, contact_type, status, city, state, country, location_query, linkedin_url, phone, mobile, created_by, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, 'investor', 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?) + id, first_name, last_name, email, title, organization_id, source, status, city, state, country, location_query, linkedin_url, phone, mobile, created_by, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( contact_id, first_name or 'Unknown', @@ -2724,9 +2729,6 @@ class CRMHandler(BaseHTTPRequestHandler): """ args = [] - if params.get('type'): - query += " AND c.contact_type = ?" - args.append(params['type']) if params.get('status'): query += " AND c.status = ?" args.append(params['status']) @@ -2743,7 +2745,7 @@ class CRMHandler(BaseHTTPRequestHandler): sort = params.get('sort', 'updated_at') order = 'DESC' if params.get('order', 'desc').lower() == 'desc' else 'ASC' - allowed_sorts = ['first_name', 'last_name', 'email', 'created_at', 'updated_at', 'contact_type', 'source'] + allowed_sorts = ['first_name', 'last_name', 'email', 'created_at', 'updated_at', 'source'] if sort in allowed_sorts: query += f" ORDER BY c.{sort} {order}" else: @@ -2832,14 +2834,14 @@ class CRMHandler(BaseHTTPRequestHandler): tags = json.dumps(body.get('tags', [])) conn.execute(""" INSERT INTO contacts (id, first_name, last_name, email, phone, mobile, title, - organization_id, contact_type, status, source, tags, notes, linkedin_url, + organization_id, status, source, tags, notes, linkedin_url, city, state, country, location_query, preferred_contact, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( contact_id, body['first_name'], body['last_name'], body.get('email'), body.get('phone'), body.get('mobile'), body.get('title'), organization_id, - body.get('contact_type', 'prospect'), body.get('status', 'active'), + body.get('status', 'active'), body.get('source'), tags, body.get('notes'), body.get('linkedin_url'), body.get('city'), body.get('state'), body.get('country'), body.get('location_query'), @@ -2871,7 +2873,7 @@ class CRMHandler(BaseHTTPRequestHandler): previous_contact = row_to_dict(existing) updatable = ['first_name', 'last_name', 'email', 'phone', 'mobile', 'title', - 'organization_id', 'contact_type', 'status', 'source', 'notes', + 'organization_id', 'status', 'source', 'notes', 'linkedin_url', 'city', 'state', 'country', 'location_query', 'preferred_contact'] sets = [] args = [] @@ -4196,10 +4198,21 @@ class CRMHandler(BaseHTTPRequestHandler): def handle_dashboard_report(self, user): conn = get_db() - # Key metrics - total_lps = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE contact_type = 'investor'").fetchone()['c'] - total_prospects = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE contact_type = 'prospect'").fetchone()['c'] - total_contacts = conn.execute("SELECT COUNT(*) as c FROM contacts").fetchone()['c'] + # Key metrics. "Total LPs" / "Prospects" are derived from the canonical grid + # (investor entities), not the retired contacts.contact_type: an LP is a grid + # investor with committed capital (total_invested > 0); a prospect is a live + # grid row with nothing committed yet. Graveyarded rows are excluded (same basis + # as Total Committed below); blank grid rows (synced as the 'Untitled Investor' + # placeholder, see sync_fundraising_relational) are excluded from the prospect + # count so empties don't inflate it. + total_lps = conn.execute( + "SELECT COUNT(*) as c FROM fundraising_investors WHERE total_invested > 0 AND graveyard = 0" + ).fetchone()['c'] + total_prospects = conn.execute( + "SELECT COUNT(*) as c FROM fundraising_investors " + "WHERE COALESCE(total_invested, 0) = 0 AND graveyard = 0 AND investor_name != 'Untitled Investor'" + ).fetchone()['c'] + total_contacts = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE deleted_at IS NULL").fetchone()['c'] # Committed capital comes from the canonical fundraising grid (per-investor # rollup of per-fund commitments). Graveyarded (written-off) investors are @@ -4495,7 +4508,6 @@ class CRMHandler(BaseHTTPRequestHandler): conn.execute(""" UPDATE contacts SET first_name=?, last_name=?, phone=?, title=?, organization_id=COALESCE(?, organization_id), - contact_type=COALESCE(?, contact_type), linkedin_url=COALESCE(?, linkedin_url), city=COALESCE(?, city), state=COALESCE(?, state), @@ -4505,7 +4517,6 @@ class CRMHandler(BaseHTTPRequestHandler): WHERE id=? """, (first_name, last_name, data.get('phone'), data.get('title'), org_id, - data.get('contact_type'), linkedin_url if linkedin_url else None, city if city else None, state if state else None, @@ -4530,12 +4541,12 @@ class CRMHandler(BaseHTTPRequestHandler): contact_id = generate_id() conn.execute(""" INSERT INTO contacts (id, first_name, last_name, email, phone, - title, organization_id, contact_type, status, source, + title, organization_id, status, source, linkedin_url, city, state, country, location_query, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?) """, (contact_id, first_name, last_name, email, data.get('phone'), data.get('title'), org_id, - data.get('contact_type', 'prospect'), linkedin_url, + linkedin_url, city, state, country, location_query, user['user_id'])) if email: batch_email_matches[email_key] = { @@ -4558,12 +4569,12 @@ class CRMHandler(BaseHTTPRequestHandler): contact_id = generate_id() conn.execute(""" INSERT INTO contacts (id, first_name, last_name, email, phone, - title, organization_id, contact_type, status, source, + title, organization_id, status, source, linkedin_url, city, state, country, location_query, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?) """, (contact_id, first_name, last_name, email, data.get('phone'), data.get('title'), org_id, - data.get('contact_type', 'prospect'), linkedin_url, + linkedin_url, city, state, country, location_query, user['user_id'])) if email: batch_email_matches[email_key] = { diff --git a/backend/test_dashboard_report.py b/backend/test_dashboard_report.py index fdf5859..d5e7ade 100644 --- a/backend/test_dashboard_report.py +++ b/backend/test_dashboard_report.py @@ -8,9 +8,15 @@ rollup) with graveyarded (written-off) investors excluded, "Total Funded" is dro (the grid has no funded-vs-committed concept), and the /api/lp-profiles* + lp-breakdown endpoints are gone. -This boots the REAL server against a temp DB, seeds two grid investors (one live, one -graveyarded), and asserts: total_committed reflects the live grid rollup only, the -metrics no longer carry a total_funded key, and the retired routes 404. Synthetic only. +v0.1.0:106 repointed "Total LPs" / "Prospects" off the retired contacts.contact_type onto +the canonical grid (investor entities): an LP = a grid investor with total_invested > 0 +(graveyard excluded); a prospect = a live grid row with $0 committed (graveyard + the +'Untitled Investor' blank-row placeholder excluded). + +This boots the REAL server against a temp DB, seeds grid investors (live LP, graveyarded, +live prospect, blank placeholder), and asserts: total_committed reflects the live grid +rollup only, total_lps / total_prospects use the grid-entity definitions, the metrics no +longer carry a total_funded key, and the retired routes 404. Synthetic only. Run: cd backend && python3 test_dashboard_report.py """ @@ -68,6 +74,17 @@ def seed(): "VALUES ('fiLive','Harbor LP','rowLive',3000000,0)") c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) " "VALUES ('fiDead','Passed LP','rowDead',500000,1)") + # a live prospect (in the grid, $0 committed) and a blank placeholder row — the prospect + # count includes the former and excludes the latter ('Untitled Investor' = a blank grid row) + c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) " + "VALUES ('fiProspect','Prospect Co','rowProspect',0,0)") + c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) " + "VALUES ('fiBlank','Untitled Investor','rowBlank',0,0)") + # one live + one soft-deleted contact: total_contacts must count only the live one + # (guards the deleted_at filter added alongside the contact_type repoint) + c.execute("INSERT INTO contacts (id,first_name,last_name) VALUES ('ctLive','Ann','Live')") + c.execute("INSERT INTO contacts (id,first_name,last_name,deleted_at) " + "VALUES ('ctGone','Bob','Gone','2026-06-01T00:00:00Z')") c.commit() c.close() @@ -90,6 +107,14 @@ def main(): check("total_funded" not in metrics, f"total_funded key dropped from metrics (got keys {sorted(metrics)})") + print("\n[Total LPs / Prospects derived from the grid, not the retired contacts.contact_type]") + check(metrics.get("total_lps") == 1, + f"total_lps = grid investors committed>0, graveyard excluded (1; got {metrics.get('total_lps')})") + check(metrics.get("total_prospects") == 1, + f"total_prospects = grid rows with $0 committed; graveyard + 'Untitled Investor' excluded (1; got {metrics.get('total_prospects')})") + check(metrics.get("total_contacts") == 1, + f"total_contacts excludes soft-deleted contacts (1; got {metrics.get('total_contacts')})") + print("\n[retired lp_profiles endpoints 404]") for path in ("/api/lp-profiles", "/api/lp-profiles/anything", "/api/reports/lp-breakdown"): st, _ = _get(port, path, token) diff --git a/frontend/index.html b/frontend/index.html index 38979f2..2b38738 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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 Prospect; + return ( + + {existing && LP} + {stage && } + + ); + }; + // 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 @@

Contacts

-
- {['all', 'investors', 'prospects'].map(t => ( - - ))} -
-
handleSort('source')} style={{ cursor: 'pointer' }}> Lead Source {sort === 'source' && (order === 'asc' ? '▲' : '▼')} - Type + Status Email Last Contact Communications @@ -5629,11 +5621,7 @@ {contact.first_name} {contact.last_name} {contact.organization || contact.organization_name || '-'} {contact.source || '-'} - - - {contact.contact_type} - - + {renderContactStatus(contact)} {contact.email || '-'} {formatDate(contact.last_contact_date)} {contact.communication_count ?? contact.comm_count ?? 0} @@ -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 @@ setEditDraft((d) => ({ ...d, country: e.target.value }))} />
-
- - -