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:
+34
-23
@@ -161,6 +161,11 @@ def init_db():
|
|||||||
mobile TEXT,
|
mobile TEXT,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
organization_id TEXT REFERENCES organizations(id) ON DELETE SET NULL,
|
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',
|
contact_type TEXT NOT NULL DEFAULT 'prospect',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
source TEXT,
|
source TEXT,
|
||||||
@@ -840,7 +845,7 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
|
|||||||
conn.execute("""
|
conn.execute("""
|
||||||
UPDATE contacts
|
UPDATE contacts
|
||||||
SET first_name = ?, last_name = ?, email = ?, title = ?,
|
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 = ?
|
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']))
|
""", (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']
|
return existing['id']
|
||||||
@@ -848,8 +853,8 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
|
|||||||
contact_id = generate_id()
|
contact_id = generate_id()
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO contacts (
|
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
|
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 (?, ?, ?, ?, ?, ?, ?, 'investor', 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (
|
""", (
|
||||||
contact_id,
|
contact_id,
|
||||||
first_name or 'Unknown',
|
first_name or 'Unknown',
|
||||||
@@ -2724,9 +2729,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
"""
|
"""
|
||||||
args = []
|
args = []
|
||||||
|
|
||||||
if params.get('type'):
|
|
||||||
query += " AND c.contact_type = ?"
|
|
||||||
args.append(params['type'])
|
|
||||||
if params.get('status'):
|
if params.get('status'):
|
||||||
query += " AND c.status = ?"
|
query += " AND c.status = ?"
|
||||||
args.append(params['status'])
|
args.append(params['status'])
|
||||||
@@ -2743,7 +2745,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
sort = params.get('sort', 'updated_at')
|
sort = params.get('sort', 'updated_at')
|
||||||
order = 'DESC' if params.get('order', 'desc').lower() == 'desc' else 'ASC'
|
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:
|
if sort in allowed_sorts:
|
||||||
query += f" ORDER BY c.{sort} {order}"
|
query += f" ORDER BY c.{sort} {order}"
|
||||||
else:
|
else:
|
||||||
@@ -2832,14 +2834,14 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
tags = json.dumps(body.get('tags', []))
|
tags = json.dumps(body.get('tags', []))
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO contacts (id, first_name, last_name, email, phone, mobile, title,
|
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)
|
city, state, country, location_query, preferred_contact, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (
|
""", (
|
||||||
contact_id, body['first_name'], body['last_name'],
|
contact_id, body['first_name'], body['last_name'],
|
||||||
body.get('email'), body.get('phone'), body.get('mobile'),
|
body.get('email'), body.get('phone'), body.get('mobile'),
|
||||||
body.get('title'), organization_id,
|
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('source'), tags, body.get('notes'),
|
||||||
body.get('linkedin_url'), body.get('city'), body.get('state'),
|
body.get('linkedin_url'), body.get('city'), body.get('state'),
|
||||||
body.get('country'), body.get('location_query'),
|
body.get('country'), body.get('location_query'),
|
||||||
@@ -2871,7 +2873,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
previous_contact = row_to_dict(existing)
|
previous_contact = row_to_dict(existing)
|
||||||
|
|
||||||
updatable = ['first_name', 'last_name', 'email', 'phone', 'mobile', 'title',
|
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']
|
'linkedin_url', 'city', 'state', 'country', 'location_query', 'preferred_contact']
|
||||||
sets = []
|
sets = []
|
||||||
args = []
|
args = []
|
||||||
@@ -4196,10 +4198,21 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
def handle_dashboard_report(self, user):
|
def handle_dashboard_report(self, user):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
|
||||||
# Key metrics
|
# Key metrics. "Total LPs" / "Prospects" are derived from the canonical grid
|
||||||
total_lps = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE contact_type = 'investor'").fetchone()['c']
|
# (investor entities), not the retired contacts.contact_type: an LP is a grid
|
||||||
total_prospects = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE contact_type = 'prospect'").fetchone()['c']
|
# investor with committed capital (total_invested > 0); a prospect is a live
|
||||||
total_contacts = conn.execute("SELECT COUNT(*) as c FROM contacts").fetchone()['c']
|
# 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
|
# Committed capital comes from the canonical fundraising grid (per-investor
|
||||||
# rollup of per-fund commitments). Graveyarded (written-off) investors are
|
# rollup of per-fund commitments). Graveyarded (written-off) investors are
|
||||||
@@ -4495,7 +4508,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
conn.execute("""
|
conn.execute("""
|
||||||
UPDATE contacts SET first_name=?, last_name=?, phone=?, title=?,
|
UPDATE contacts SET first_name=?, last_name=?, phone=?, title=?,
|
||||||
organization_id=COALESCE(?, organization_id),
|
organization_id=COALESCE(?, organization_id),
|
||||||
contact_type=COALESCE(?, contact_type),
|
|
||||||
linkedin_url=COALESCE(?, linkedin_url),
|
linkedin_url=COALESCE(?, linkedin_url),
|
||||||
city=COALESCE(?, city),
|
city=COALESCE(?, city),
|
||||||
state=COALESCE(?, state),
|
state=COALESCE(?, state),
|
||||||
@@ -4505,7 +4517,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
WHERE id=?
|
WHERE id=?
|
||||||
""", (first_name, last_name, data.get('phone'),
|
""", (first_name, last_name, data.get('phone'),
|
||||||
data.get('title'), org_id,
|
data.get('title'), org_id,
|
||||||
data.get('contact_type'),
|
|
||||||
linkedin_url if linkedin_url else None,
|
linkedin_url if linkedin_url else None,
|
||||||
city if city else None,
|
city if city else None,
|
||||||
state if state else None,
|
state if state else None,
|
||||||
@@ -4530,12 +4541,12 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
contact_id = generate_id()
|
contact_id = generate_id()
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO contacts (id, first_name, last_name, email, phone,
|
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)
|
linkedin_url, city, state, country, location_query, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
|
||||||
""", (contact_id, first_name, last_name, email,
|
""", (contact_id, first_name, last_name, email,
|
||||||
data.get('phone'), data.get('title'), org_id,
|
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']))
|
city, state, country, location_query, user['user_id']))
|
||||||
if email:
|
if email:
|
||||||
batch_email_matches[email_key] = {
|
batch_email_matches[email_key] = {
|
||||||
@@ -4558,12 +4569,12 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
contact_id = generate_id()
|
contact_id = generate_id()
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO contacts (id, first_name, last_name, email, phone,
|
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)
|
linkedin_url, city, state, country, location_query, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
|
||||||
""", (contact_id, first_name, last_name, email,
|
""", (contact_id, first_name, last_name, email,
|
||||||
data.get('phone'), data.get('title'), org_id,
|
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']))
|
city, state, country, location_query, user['user_id']))
|
||||||
if email:
|
if email:
|
||||||
batch_email_matches[email_key] = {
|
batch_email_matches[email_key] = {
|
||||||
|
|||||||
@@ -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
|
(the grid has no funded-vs-committed concept), and the /api/lp-profiles* + lp-breakdown
|
||||||
endpoints are gone.
|
endpoints are gone.
|
||||||
|
|
||||||
This boots the REAL server against a temp DB, seeds two grid investors (one live, one
|
v0.1.0:106 repointed "Total LPs" / "Prospects" off the retired contacts.contact_type onto
|
||||||
graveyarded), and asserts: total_committed reflects the live grid rollup only, the
|
the canonical grid (investor entities): an LP = a grid investor with total_invested > 0
|
||||||
metrics no longer carry a total_funded key, and the retired routes 404. Synthetic only.
|
(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
|
Run: cd backend && python3 test_dashboard_report.py
|
||||||
"""
|
"""
|
||||||
@@ -68,6 +74,17 @@ def seed():
|
|||||||
"VALUES ('fiLive','Harbor LP','rowLive',3000000,0)")
|
"VALUES ('fiLive','Harbor LP','rowLive',3000000,0)")
|
||||||
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
|
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
|
||||||
"VALUES ('fiDead','Passed LP','rowDead',500000,1)")
|
"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.commit()
|
||||||
c.close()
|
c.close()
|
||||||
|
|
||||||
@@ -90,6 +107,14 @@ def main():
|
|||||||
check("total_funded" not in metrics,
|
check("total_funded" not in metrics,
|
||||||
f"total_funded key dropped from metrics (got keys {sorted(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]")
|
print("\n[retired lp_profiles endpoints 404]")
|
||||||
for path in ("/api/lp-profiles", "/api/lp-profiles/anything", "/api/reports/lp-breakdown"):
|
for path in ("/api/lp-profiles", "/api/lp-profiles/anything", "/api/reports/lp-breakdown"):
|
||||||
st, _ = _get(port, path, token)
|
st, _ = _get(port, path, token)
|
||||||
|
|||||||
+24
-52
@@ -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
|
// 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
|
// 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).
|
// only when the investor has committed capital (existing_investor / committed > 0).
|
||||||
@@ -5507,7 +5523,6 @@
|
|||||||
const [contactsPage, setContactsPage] = useState(1);
|
const [contactsPage, setContactsPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [tab, setTab] = useState('all');
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [sort, setSort] = useState('last_name');
|
const [sort, setSort] = useState('last_name');
|
||||||
const [order, setOrder] = useState('asc');
|
const [order, setOrder] = useState('asc');
|
||||||
@@ -5515,19 +5530,18 @@
|
|||||||
const [deleting, setDeleting] = useState(null);
|
const [deleting, setDeleting] = useState(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||||
|
|
||||||
const tabFilter = tab === 'all' ? '' : tab === 'investors' ? 'investor' : 'prospect';
|
|
||||||
const contactsOffset = (contactsPage - 1) * CONTACTS_PAGE_SIZE;
|
const contactsOffset = (contactsPage - 1) * CONTACTS_PAGE_SIZE;
|
||||||
const contactsMaxPage = Math.max(1, Math.ceil((Number(contactsTotal) || 0) / CONTACTS_PAGE_SIZE));
|
const contactsMaxPage = Math.max(1, Math.ceil((Number(contactsTotal) || 0) / CONTACTS_PAGE_SIZE));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setContactsPage(1);
|
setContactsPage(1);
|
||||||
}, [tab, search, sort, order]);
|
}, [search, sort, order]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchContacts = async () => {
|
const fetchContacts = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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 || []);
|
setContacts(result.data || []);
|
||||||
setContactsTotal(Number(result.total) || 0);
|
setContactsTotal(Number(result.total) || 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -5538,7 +5552,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchContacts();
|
fetchContacts();
|
||||||
}, [token, tab, search, sort, order, contactsOffset]);
|
}, [token, search, sort, order, contactsOffset]);
|
||||||
|
|
||||||
const handleDeleteContact = async (id) => {
|
const handleDeleteContact = async (id) => {
|
||||||
setDeleting(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) => {
|
const handleSort = (column) => {
|
||||||
if (sort === column) {
|
if (sort === column) {
|
||||||
setOrder(order === 'asc' ? 'desc' : 'asc');
|
setOrder(order === 'asc' ? 'desc' : 'asc');
|
||||||
@@ -5579,18 +5583,6 @@
|
|||||||
<h2 className="section-title">Contacts</h2>
|
<h2 className="section-title">Contacts</h2>
|
||||||
|
|
||||||
<div className="section">
|
<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">
|
<div className="controls">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -5617,7 +5609,7 @@
|
|||||||
<th onClick={() => handleSort('source')} style={{ cursor: 'pointer' }}>
|
<th onClick={() => handleSort('source')} style={{ cursor: 'pointer' }}>
|
||||||
Lead Source {sort === 'source' && (order === 'asc' ? '▲' : '▼')}
|
Lead Source {sort === 'source' && (order === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<th>Type</th>
|
<th>Status</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Last Contact</th>
|
<th>Last Contact</th>
|
||||||
<th>Communications</th>
|
<th>Communications</th>
|
||||||
@@ -5629,11 +5621,7 @@
|
|||||||
<td>{contact.first_name} {contact.last_name}</td>
|
<td>{contact.first_name} {contact.last_name}</td>
|
||||||
<td>{contact.organization || contact.organization_name || '-'}</td>
|
<td>{contact.organization || contact.organization_name || '-'}</td>
|
||||||
<td>{contact.source || '-'}</td>
|
<td>{contact.source || '-'}</td>
|
||||||
<td>
|
<td>{renderContactStatus(contact)}</td>
|
||||||
<span className={`badge ${contactTypeTooltip(contact.contact_type)}`}>
|
|
||||||
{contact.contact_type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{contact.email || '-'}</td>
|
<td>{contact.email || '-'}</td>
|
||||||
<td>{formatDate(contact.last_contact_date)}</td>
|
<td>{formatDate(contact.last_contact_date)}</td>
|
||||||
<td>{contact.communication_count ?? contact.comm_count ?? 0}</td>
|
<td>{contact.communication_count ?? contact.comm_count ?? 0}</td>
|
||||||
@@ -5677,7 +5665,7 @@
|
|||||||
onDelete={() => setConfirmDelete(selectedContact.id)}
|
onDelete={() => setConfirmDelete(selectedContact.id)}
|
||||||
token={token}
|
token={token}
|
||||||
onRefresh={() => {
|
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 => {
|
result.then(r => {
|
||||||
setContacts(r.data || []);
|
setContacts(r.data || []);
|
||||||
setContactsTotal(Number(r.total) || 0);
|
setContactsTotal(Number(r.total) || 0);
|
||||||
@@ -6017,7 +6005,6 @@
|
|||||||
city: '',
|
city: '',
|
||||||
state: '',
|
state: '',
|
||||||
country: '',
|
country: '',
|
||||||
contact_type: 'prospect',
|
|
||||||
status: 'active'
|
status: 'active'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6050,7 +6037,6 @@
|
|||||||
city: details.city || '',
|
city: details.city || '',
|
||||||
state: details.state || '',
|
state: details.state || '',
|
||||||
country: details.country || '',
|
country: details.country || '',
|
||||||
contact_type: details.contact_type || 'prospect',
|
|
||||||
status: details.status || 'active'
|
status: details.status || 'active'
|
||||||
});
|
});
|
||||||
}, [details]);
|
}, [details]);
|
||||||
@@ -6076,7 +6062,6 @@
|
|||||||
city: editDraft.city.trim(),
|
city: editDraft.city.trim(),
|
||||||
state: editDraft.state.trim(),
|
state: editDraft.state.trim(),
|
||||||
country: editDraft.country.trim(),
|
country: editDraft.country.trim(),
|
||||||
contact_type: editDraft.contact_type,
|
|
||||||
status: editDraft.status
|
status: editDraft.status
|
||||||
})
|
})
|
||||||
}, token);
|
}, token);
|
||||||
@@ -6156,15 +6141,6 @@
|
|||||||
<label className="form-label">Country</label>
|
<label className="form-label">Country</label>
|
||||||
<input className="text-input" value={editDraft.country} onChange={(e) => setEditDraft((d) => ({ ...d, country: e.target.value }))} />
|
<input className="text-input" value={editDraft.country} onChange={(e) => setEditDraft((d) => ({ ...d, country: e.target.value }))} />
|
||||||
</div>
|
</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">
|
<div className="form-group">
|
||||||
<label className="form-label">Status</label>
|
<label className="form-label">Status</label>
|
||||||
<select className="select-input" value={editDraft.status} onChange={(e) => setEditDraft((d) => ({ ...d, status: e.target.value }))}>
|
<select className="select-input" value={editDraft.status} onChange={(e) => setEditDraft((d) => ({ ...d, status: e.target.value }))}>
|
||||||
@@ -6207,12 +6183,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-row">
|
<div className="detail-row">
|
||||||
<span className="detail-label">Type</span>
|
<span className="detail-label">Status</span>
|
||||||
<span className="detail-value">
|
<span className="detail-value">{renderContactStatus(details)}</span>
|
||||||
<span className={`badge ${details.contact_type === 'investor' ? 'badge-investor' : 'badge-prospect'}`}>
|
|
||||||
{details.contact_type}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
|||||||
// * 0.1.0:103 (Reminders require a due date [Grant feedback]: every reminder-create flow now pre-fills the due date to +1 week [editable] and blocks an empty save — a date-less reminder has no urgency [it falls to the "Later"/"No date" bucket, out of the overdue/today/this-week rollups + daily digest]. Applies to ALL create surfaces via a shared `reminderDefaultDue()` helper — mobile: the add-investor sheet [date auto-fills when you start the optional reminder], the standalone Reminders "New reminder" sheet, the Grid-detail "Set a reminder" card; desktop: the Reminders page "+ New reminder" + the grid reminder modal. Edit paths also pre-fill the default for legacy date-less reminders. Frontend-only; no schema/migration/dependency change)
|
// * 0.1.0:103 (Reminders require a due date [Grant feedback]: every reminder-create flow now pre-fills the due date to +1 week [editable] and blocks an empty save — a date-less reminder has no urgency [it falls to the "Later"/"No date" bucket, out of the overdue/today/this-week rollups + daily digest]. Applies to ALL create surfaces via a shared `reminderDefaultDue()` helper — mobile: the add-investor sheet [date auto-fills when you start the optional reminder], the standalone Reminders "New reminder" sheet, the Grid-detail "Set a reminder" card; desktop: the Reminders page "+ New reminder" + the grid reminder modal. Edit paths also pre-fill the default for legacy date-less reminders. Frontend-only; no schema/migration/dependency change)
|
||||||
// * 0.1.0:104 (Remove the Instructions + Feedback [feature_requests] pages + backend, and retire the empty lp_profiles table + investor_type — a one-off sanctioned exception to never-hard-delete; in-app migration 0008 drops lp_profiles + feature_requests, and 0001's lp_profiles ALTER was removed so a fresh DB doesn't break the migration chain. Fixes: email sync no longer terminally parks a mailbox on a transient timeout [auto-retry + hourly backoff → stuck mailboxes self-heal]; mobile Contacts pages through ALL contacts [a single 500-row fetch truncated at 720, hiding people from the list + search]; a clock icon on the mobile email Review-log sets a reminder inline; email-approval cards show date/time. New: admin-only purge of soft-deleted rows [type-to-confirm; refuses any row still linked to live data])
|
// * 0.1.0:104 (Remove the Instructions + Feedback [feature_requests] pages + backend, and retire the empty lp_profiles table + investor_type — a one-off sanctioned exception to never-hard-delete; in-app migration 0008 drops lp_profiles + feature_requests, and 0001's lp_profiles ALTER was removed so a fresh DB doesn't break the migration chain. Fixes: email sync no longer terminally parks a mailbox on a transient timeout [auto-retry + hourly backoff → stuck mailboxes self-heal]; mobile Contacts pages through ALL contacts [a single 500-row fetch truncated at 720, hiding people from the list + search]; a clock icon on the mobile email Review-log sets a reminder inline; email-approval cards show date/time. New: admin-only purge of soft-deleted rows [type-to-confirm; refuses any row still linked to live data])
|
||||||
// * Current: 0.1.0:105 (TEMPORARY diagnostic — admin contacts census [GET /api/admin/contacts-census + a Settings → Admin "Run census" button] reporting the A/B/C populations [counts only, no PII] for the deferred contacts<->fundraising_contacts consolidation; mirrors backend/scripts/contacts_census.sql. DELETE the endpoint + route + button after the numbers are captured — all tagged TEMPORARY in code. No schema change)
|
// * Current: 0.1.0:105 (TEMPORARY diagnostic — admin contacts census [GET /api/admin/contacts-census + a Settings → Admin "Run census" button] reporting the A/B/C populations [counts only, no PII] for the deferred contacts<->fundraising_contacts consolidation; mirrors backend/scripts/contacts_census.sql. DELETE the endpoint + route + button after the numbers are captured — all tagged TEMPORARY in code. No schema change)
|
||||||
export const PACKAGE_VERSION = '0.1.0:105'
|
export const PACKAGE_VERSION = '0.1.0:106'
|
||||||
|
|
||||||
export const DATA_MOUNT_PATH = '/data'
|
export const DATA_MOUNT_PATH = '/data'
|
||||||
export const WEB_PORT = 8080
|
export const WEB_PORT = 8080
|
||||||
|
|||||||
@@ -66,8 +66,9 @@ import { v_0_1_0_102 } from './v0.1.0.102'
|
|||||||
import { v_0_1_0_103 } from './v0.1.0.103'
|
import { v_0_1_0_103 } from './v0.1.0.103'
|
||||||
import { v_0_1_0_104 } from './v0.1.0.104'
|
import { v_0_1_0_104 } from './v0.1.0.104'
|
||||||
import { v_0_1_0_105 } from './v0.1.0.105'
|
import { v_0_1_0_105 } from './v0.1.0.105'
|
||||||
|
import { v_0_1_0_106 } from './v0.1.0.106'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_1_0_105,
|
current: v_0_1_0_106,
|
||||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99, v_0_1_0_100, v_0_1_0_101, v_0_1_0_102, v_0_1_0_103, v_0_1_0_104],
|
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99, v_0_1_0_100, v_0_1_0_101, v_0_1_0_102, v_0_1_0_103, v_0_1_0_104, v_0_1_0_105],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
// v0.1.0:106 — Retire contacts.contact_type (logical). The Investors/Prospects distinction is now
|
||||||
|
// derived live from the canonical grid (contact_grid_signals → committed/pipeline_stage): the desktop
|
||||||
|
// Contacts tabs + TYPE badge are replaced by a derived Status (existing-LP + pipeline stage), and the
|
||||||
|
// dashboard "Total LPs"/"Prospects" counts are repointed onto fundraising_investors entities. The
|
||||||
|
// column is left physically in place but inert (no reader/writer); a physical DROP is deferred to a
|
||||||
|
// later signed-off table-rebuild migration. No schema change in this release.
|
||||||
|
export const v_0_1_0_106 = VersionInfo.of({
|
||||||
|
version: '0.1.0:106',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: 'Retire the legacy contact type: Contacts now shows grid-derived status (existing LP + pipeline stage) and the dashboard LP/prospect counts come from the fundraising grid.',
|
||||||
|
},
|
||||||
|
migrations: { up: async () => {}, down: async () => {} },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user