Mobile Phase 8h: Grid detail stage/reminder cards + Open-in-Grid deep-link
Grid full-screen investor detail, conformed to the dc anatomy: - G4: pipeline stage as a single tappable .detail-tap-card (chip + Change/Add) - G5: dedicated Reminder card fed by the soonest active reminder; tri-state (loading → disabled "Checking…" so a pre-load tap can't POST a duplicate; none → "No reminder set"; object → edit). Edits PATCH in place, else POST. - G6 (notes timeline) was already in place. Open-in-Grid deep-link, now on all three mobile detail surfaces (Contacts, Pipeline, Reminders): a shared shell openInvestorInGrid(rowId) sets a one-shot gridUiAction object the mobile grid consumes on mount to open that investor's detail; the desktop grid drains the unrecognized object so it can't linger. Each surface gets its grid row id from a server-injected source_row_id: contacts via contact_grid_signals, opportunities via the durable fundraising_investor_id join, reminders via the investor_id join. All are read-only/GET-only or field-allowlist writes, so none need a strip point. Tests: source_row_id injection assertions for contacts, opportunities, and reminders; full suite 40/40. Client surfaces jsdom-verified.
This commit is contained in:
+21
-6
@@ -1917,7 +1917,10 @@ def contact_grid_signals(conn, contact_id=None):
|
||||
# highest-committed one (its stage + priority) so the signals reflect the strongest link.
|
||||
if prev is None or committed > prev['committed']:
|
||||
out[cid] = {'committed': committed, 'pipeline_stage': stage_by_srid.get(str(r['srid'] or '')),
|
||||
'priority': bool(r['priority'])}
|
||||
'priority': bool(r['priority']),
|
||||
# None (not '') when unset, matching the caller's `... if sig else None`;
|
||||
# source_row_id is NOT NULL on real investors so this is belt-and-suspenders.
|
||||
'source_row_id': str(r['srid']) if r['srid'] else None}
|
||||
return out
|
||||
|
||||
|
||||
@@ -2718,14 +2721,16 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
|
||||
contacts = rows_to_list(conn.execute(query, args).fetchall())
|
||||
# Enrich with read-only, live-derived grid signals (committed → existing-LP avatar ring,
|
||||
# pipeline_stage → stage pill) for the mobile Contacts card. Harmless extra fields for the
|
||||
# desktop page, which ignores them. Never persisted on the contact (the list is read-only).
|
||||
# pipeline_stage → stage pill, source_row_id → the detail's "Open investor in Grid"
|
||||
# deep-link target) for the mobile Contacts card. Harmless extra fields for the desktop
|
||||
# page, which ignores them. Never persisted on the contact (the list is read-only).
|
||||
signals = contact_grid_signals(conn)
|
||||
for c in contacts:
|
||||
sig = signals.get(str(c.get('id') or ''))
|
||||
c['committed'] = sig['committed'] if sig else 0
|
||||
c['pipeline_stage'] = sig['pipeline_stage'] if sig else None
|
||||
c['priority'] = bool(sig['priority']) if sig else False
|
||||
c['source_row_id'] = sig['source_row_id'] if sig else None
|
||||
conn.close()
|
||||
|
||||
return self.send_json({
|
||||
@@ -2764,12 +2769,13 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
).fetchall())
|
||||
|
||||
# Same read-only grid signals the list injects (committed → existing-LP ring/pill,
|
||||
# pipeline_stage → stage pill), so the mobile detail sheet can render them without a
|
||||
# second round-trip. Derived live; never stored on the contact.
|
||||
# pipeline_stage → stage pill, source_row_id → "Open investor in Grid" deep-link), so the
|
||||
# mobile detail sheet can render them without a second round-trip. Derived live; never stored.
|
||||
sig = contact_grid_signals(conn, contact_id).get(contact_id)
|
||||
result['committed'] = sig['committed'] if sig else 0
|
||||
result['pipeline_stage'] = sig['pipeline_stage'] if sig else None
|
||||
result['priority'] = bool(sig['priority']) if sig else False
|
||||
result['source_row_id'] = sig['source_row_id'] if sig else None
|
||||
|
||||
conn.close()
|
||||
return self.send_json({"data": result})
|
||||
@@ -3015,12 +3021,14 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
query = """
|
||||
SELECT op.*, c.first_name, c.last_name, c.email as contact_email,
|
||||
o.name as organization_name, u.full_name as owner_name,
|
||||
fi.source_row_id as source_row_id, -- the fundraising_investors row id (opportunities has no such column)
|
||||
(SELECT MAX(communication_date) FROM communications
|
||||
WHERE contact_id = op.contact_id AND deleted_at IS NULL) as last_contact_date
|
||||
FROM opportunities op
|
||||
LEFT JOIN contacts c ON op.contact_id = c.id
|
||||
LEFT JOIN organizations o ON op.organization_id = o.id
|
||||
LEFT JOIN users u ON op.owner_id = u.id
|
||||
LEFT JOIN fundraising_investors fi ON fi.id = op.fundraising_investor_id
|
||||
WHERE 1=1 AND op.deleted_at IS NULL
|
||||
"""
|
||||
args = []
|
||||
@@ -3055,6 +3063,9 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
# committed > 0), so card earmark and detail pill agree. Derived on GET, never persisted;
|
||||
# opportunity writes use a field allowlist (handle_update_opportunity) so it can't round-trip.
|
||||
# `last_contact_date` (subselect above) drives the card recency line + the Staleness sort.
|
||||
# `source_row_id` (joined above via the durable fundraising_investor_id, not the contact path)
|
||||
# is the 8h "Open investor in Grid" deep-link target for the Pipeline detail; null for an opp
|
||||
# not linked to a grid investor (a legacy/manual deal).
|
||||
signals = contact_grid_signals(conn)
|
||||
for op in opps:
|
||||
sig = signals.get(str(op.get('contact_id') or ''))
|
||||
@@ -3790,10 +3801,14 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT r.*, ua.username AS assignee_name, uc.username AS creator_name "
|
||||
# fi.source_row_id (joined via the reminder's investor_id) is the 8h "Open investor
|
||||
# in Grid" deep-link target for the Reminders edit sheet; null for an unlinked reminder.
|
||||
"SELECT r.*, ua.username AS assignee_name, uc.username AS creator_name, "
|
||||
"fi.source_row_id AS source_row_id "
|
||||
"FROM reminders r "
|
||||
"LEFT JOIN users ua ON ua.id = r.assignee_id "
|
||||
"LEFT JOIN users uc ON uc.id = r.created_by "
|
||||
"LEFT JOIN fundraising_investors fi ON fi.id = r.investor_id "
|
||||
"WHERE " + " AND ".join(where) +
|
||||
# dated reminders first (soonest due), then undated by recency
|
||||
" ORDER BY (r.due_date IS NULL OR r.due_date = ''), r.due_date ASC, r.created_at ASC",
|
||||
|
||||
Reference in New Issue
Block a user