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:
Keysat
2026-06-20 07:08:29 -05:00
parent f645288fc3
commit 707a270922
6 changed files with 200 additions and 44 deletions
+4 -4
View File
File diff suppressed because one or more lines are too long
+21 -6
View File
@@ -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. # highest-committed one (its stage + priority) so the signals reflect the strongest link.
if prev is None or committed > prev['committed']: if prev is None or committed > prev['committed']:
out[cid] = {'committed': committed, 'pipeline_stage': stage_by_srid.get(str(r['srid'] or '')), 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 return out
@@ -2718,14 +2721,16 @@ class CRMHandler(BaseHTTPRequestHandler):
contacts = rows_to_list(conn.execute(query, args).fetchall()) contacts = rows_to_list(conn.execute(query, args).fetchall())
# Enrich with read-only, live-derived grid signals (committed → existing-LP avatar ring, # 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 # pipeline_stage → stage pill, source_row_id → the detail's "Open investor in Grid"
# desktop page, which ignores them. Never persisted on the contact (the list is read-only). # 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) signals = contact_grid_signals(conn)
for c in contacts: for c in contacts:
sig = signals.get(str(c.get('id') or '')) sig = signals.get(str(c.get('id') or ''))
c['committed'] = sig['committed'] if sig else 0 c['committed'] = sig['committed'] if sig else 0
c['pipeline_stage'] = sig['pipeline_stage'] if sig else None c['pipeline_stage'] = sig['pipeline_stage'] if sig else None
c['priority'] = bool(sig['priority']) if sig else False c['priority'] = bool(sig['priority']) if sig else False
c['source_row_id'] = sig['source_row_id'] if sig else None
conn.close() conn.close()
return self.send_json({ return self.send_json({
@@ -2764,12 +2769,13 @@ class CRMHandler(BaseHTTPRequestHandler):
).fetchall()) ).fetchall())
# Same read-only grid signals the list injects (committed → existing-LP ring/pill, # 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 # pipeline_stage → stage pill, source_row_id → "Open investor in Grid" deep-link), so the
# second round-trip. Derived live; never stored on the contact. # 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) sig = contact_grid_signals(conn, contact_id).get(contact_id)
result['committed'] = sig['committed'] if sig else 0 result['committed'] = sig['committed'] if sig else 0
result['pipeline_stage'] = sig['pipeline_stage'] if sig else None result['pipeline_stage'] = sig['pipeline_stage'] if sig else None
result['priority'] = bool(sig['priority']) if sig else False result['priority'] = bool(sig['priority']) if sig else False
result['source_row_id'] = sig['source_row_id'] if sig else None
conn.close() conn.close()
return self.send_json({"data": result}) return self.send_json({"data": result})
@@ -3015,12 +3021,14 @@ class CRMHandler(BaseHTTPRequestHandler):
query = """ query = """
SELECT op.*, c.first_name, c.last_name, c.email as contact_email, SELECT op.*, c.first_name, c.last_name, c.email as contact_email,
o.name as organization_name, u.full_name as owner_name, 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 (SELECT MAX(communication_date) FROM communications
WHERE contact_id = op.contact_id AND deleted_at IS NULL) as last_contact_date WHERE contact_id = op.contact_id AND deleted_at IS NULL) as last_contact_date
FROM opportunities op FROM opportunities op
LEFT JOIN contacts c ON op.contact_id = c.id LEFT JOIN contacts c ON op.contact_id = c.id
LEFT JOIN organizations o ON op.organization_id = o.id LEFT JOIN organizations o ON op.organization_id = o.id
LEFT JOIN users u ON op.owner_id = u.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 WHERE 1=1 AND op.deleted_at IS NULL
""" """
args = [] args = []
@@ -3055,6 +3063,9 @@ class CRMHandler(BaseHTTPRequestHandler):
# committed > 0), so card earmark and detail pill agree. Derived on GET, never persisted; # 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. # 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. # `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) signals = contact_grid_signals(conn)
for op in opps: for op in opps:
sig = signals.get(str(op.get('contact_id') or '')) sig = signals.get(str(op.get('contact_id') or ''))
@@ -3790,10 +3801,14 @@ class CRMHandler(BaseHTTPRequestHandler):
conn = get_db() conn = get_db()
try: try:
rows = conn.execute( 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 " "FROM reminders r "
"LEFT JOIN users ua ON ua.id = r.assignee_id " "LEFT JOIN users ua ON ua.id = r.assignee_id "
"LEFT JOIN users uc ON uc.id = r.created_by " "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) + "WHERE " + " AND ".join(where) +
# dated reminders first (soonest due), then undated by recency # 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", " ORDER BY (r.due_date IS NULL OR r.due_date = ''), r.due_date ASC, r.created_at ASC",
+16 -1
View File
@@ -8,7 +8,10 @@ sourced from the fundraising grid (the canonical investor model), for the mobile
- `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill), - `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill),
or null when the investor isn't in the pipeline. or null when the investor isn't in the pipeline.
- `priority` -> that investor's priority flag (drives the mobile Contacts Priority sort, 8d). - `priority` -> that investor's priority flag (drives the mobile Contacts Priority sort, 8d).
A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage null / priority false. - `source_row_id` -> that investor's grid row id (the "Open investor in Grid" deep-link target, 8h),
present for ANY grid-linked contact (even a zero-commit prospect), null otherwise.
A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage null / priority false
/ source_row_id null.
Signals are derived fresh on read and never stored on the contact. Synthetic data only. Signals are derived fresh on read and never stored on the contact. Synthetic data only.
Run: cd backend && python3 test_contacts_grid_signals.py Run: cd backend && python3 test_contacts_grid_signals.py
@@ -148,6 +151,15 @@ def main():
check((vince or {}).get("priority") is False, check((vince or {}).get("priority") is False,
f"Vince.priority is False (no grid link) (got {(vince or {}).get('priority')!r})") f"Vince.priority is False (no grid link) (got {(vince or {}).get('priority')!r})")
# ── source_row_id signal: the "Open investor in Grid" deep-link target (8h) ──
print("\n[source_row_id: Open-in-Grid deep-link target = the linked investor's grid row id]")
check((jane or {}).get("source_row_id") == "rowAcme",
f"Jane.source_row_id == 'rowAcme' (got {(jane or {}).get('source_row_id')!r})")
check((pat or {}).get("source_row_id") == "rowBeta",
f"Pat.source_row_id == 'rowBeta' (present for a zero-commit linked contact) (got {(pat or {}).get('source_row_id')!r})")
check((vince or {}).get("source_row_id") is None,
f"Vince.source_row_id is None (no grid link) (got {(vince or {}).get('source_row_id')!r})")
# ── the get-by-id endpoint carries the same signals (mobile detail sheet, 8b) ── # ── the get-by-id endpoint carries the same signals (mobile detail sheet, 8b) ──
print("\n[get-by-id: /api/contacts/{id} also injects committed + pipeline_stage]") print("\n[get-by-id: /api/contacts/{id} also injects committed + pipeline_stage]")
st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token) st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token)
@@ -193,6 +205,9 @@ def main():
# The winning (higher-committed) link is Mega Fund LP, which is not flagged → priority follows it. # The winning (higher-committed) link is Mega Fund LP, which is not flagged → priority follows it.
check(jd.get("priority") is False, check(jd.get("priority") is False,
f"multi-linked contact's priority follows the higher-committed investor (Mega, unflagged) (got {jd.get('priority')!r})") f"multi-linked contact's priority follows the higher-committed investor (Mega, unflagged) (got {jd.get('priority')!r})")
# The deep-link target also follows the winning link → Mega's grid row (rowMega), not rowAcme.
check(jd.get("source_row_id") == "rowMega",
f"multi-linked contact's source_row_id follows the higher-committed investor (rowMega) (got {jd.get('source_row_id')!r})")
finally: finally:
httpd.shutdown() httpd.shutdown()
+18
View File
@@ -135,6 +135,7 @@ def main():
check(bool(fr_id), f"opportunity carries fundraising_investor_id (got {fr_id})") check(bool(fr_id), f"opportunity carries fundraising_investor_id (got {fr_id})")
check(_opp_count_live(fr_id) == 1, "exactly one live opp linked to the investor") check(_opp_count_live(fr_id) == 1, "exactly one live opp linked to the investor")
opp_id = opp.get("id") opp_id = opp.get("id")
jane_contact_id = opp.get("contact_id")
# ── idempotent re-link: returns existing, board-owned stage NOT reseeded ── # ── idempotent re-link: returns existing, board-owned stage NOT reseeded ──
print("\n[idempotent: re-link returns existing opp without reseeding funnel fields]") print("\n[idempotent: re-link returns existing opp without reseeding funnel fields]")
@@ -222,6 +223,11 @@ def main():
st, d = _req(port, "GET", "/api/opportunities?limit=1000", token) st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
ids = [o["id"] for o in (d or {}).get("data", [])] ids = [o["id"] for o in (d or {}).get("data", [])]
check(opp_id in ids, "linked opp appears on the board") check(opp_id in ids, "linked opp appears on the board")
# 8h deep-link: the opp list injects source_row_id from the durable fundraising_investor_id
# (the mobile Pipeline detail's "Open investor in Grid" target).
acme_opp = next((o for o in (d or {}).get("data", []) if o["id"] == opp_id), {})
check(acme_opp.get("source_row_id") == "rowAcme",
f"opp list injects source_row_id == 'rowAcme' (got {acme_opp.get('source_row_id')!r})")
st, d = _req(port, "GET", "/api/reports/dashboard", token) st, d = _req(port, "GET", "/api/reports/dashboard", token)
active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities") active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities")
check(active == 1, f"dashboard active_opportunities == 1 (got {active})") check(active == 1, f"dashboard active_opportunities == 1 (got {active})")
@@ -281,6 +287,18 @@ def main():
check(_opp_count_live(beta_fr) == 0, "beta's orphaned opp archived by the reconciler") check(_opp_count_live(beta_fr) == 0, "beta's orphaned opp archived by the reconciler")
st, d = _req(port, "GET", "/api/opportunities?limit=1000", token) st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
check(beta_opp_id not in [o["id"] for o in (d or {}).get("data", [])], "orphaned opp left the board") check(beta_opp_id not in [o["id"] for o in (d or {}).get("data", [])], "orphaned opp left the board")
# ── 8h: a manually-created deal (no fundraising_investor_id) has a null source_row_id, so
# the mobile Pipeline detail hides "Open investor in Grid" for it ──
print("\n[8h: opp source_row_id is null for a deal with no grid link]")
st, d = _req(port, "POST", "/api/opportunities", token,
{"name": "Manual deal", "contact_id": jane_contact_id, "stage": "lead"})
manual_id = (d or {}).get("data", {}).get("id")
check(st in (200, 201) and bool(manual_id), f"create a manual (non-grid) opp (got {st})")
st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
manual = next((o for o in (d or {}).get("data", []) if o["id"] == manual_id), {})
check(manual.get("source_row_id") in (None, ""),
f"manual opp has null source_row_id (got {manual.get('source_row_id')!r})")
finally: finally:
httpd.shutdown() httpd.shutdown()
+7
View File
@@ -158,6 +158,13 @@ def main():
items = (d or {}).get("data", []) items = (d or {}).get("data", [])
check(st == 200 and len(items) == 4, f"list returns all 4 open (got {st}, {len(items)})") check(st == 200 and len(items) == 4, f"list returns all 4 open (got {st}, {len(items)})")
check(all("last_activity_at" in it for it in items), "each row carries last_activity_at") check(all("last_activity_at" in it for it in items), "each row carries last_activity_at")
# 8h deep-link: each row injects source_row_id (joined via investor_id) — the grid row id
# the mobile Reminders edit sheet uses for "Open investor in Grid"; null for a team task.
by_id = {it.get("id"): it for it in items}
check(by_id.get(acme_rem_id, {}).get("source_row_id") == "rowAcme",
f"investor reminder injects source_row_id == 'rowAcme' (got {by_id.get(acme_rem_id, {}).get('source_row_id')!r})")
check(by_id.get(standalone_id, {}).get("source_row_id") in (None, ""),
f"team task has null source_row_id -> Open-in-Grid hidden (got {by_id.get(standalone_id, {}).get('source_row_id')!r})")
# dated reminders sort before undated, soonest first -> YESTERDAY (beta) leads # dated reminders sort before undated, soonest first -> YESTERDAY (beta) leads
check(items and items[0].get("id") == beta_rem_id, f"overdue sorts first (got {items[0].get('id') if items else None})") check(items and items[0].get("id") == beta_rem_id, f"overdue sorts first (got {items[0].get('id') if items else None})")
+134 -33
View File
@@ -2541,6 +2541,22 @@
.fs-pill { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 9px 12px; margin-bottom: 8px; } .fs-pill { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 9px 12px; margin-bottom: 8px; }
.fs-pill-name { font-size: var(--mobile-font-body); color: var(--text-primary); } .fs-pill-name { font-size: var(--mobile-font-body); color: var(--text-primary); }
.fs-pill-email { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); margin-top: 2px; word-break: break-word; } .fs-pill-email { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); margin-top: 2px; word-break: break-word; }
/* G4/G5 — tappable detail cards (Grid full-screen detail): single-tap stage + reminder
cards matching the dc stage/reminder anatomy (panel card, inline chip/note, chevron). */
.detail-tap-card {
width: 100%; text-align: left; cursor: pointer; font-family: inherit;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-card-radius); padding: 14px 16px;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
color: var(--text-primary);
}
.detail-tap-card:active { border-color: var(--border-strong); }
.detail-tap-card:disabled { cursor: default; } /* G5 loading state — not yet tappable */
.detail-tap-card-left { display: flex; align-items: center; gap: 10px; min-width: 0; }
.detail-tap-card-col { display: flex; flex-direction: column; align-items: flex-start; gap: 5px; min-width: 0; }
.detail-tap-card-note { font-size: 14px; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.detail-tap-card-empty { font-size: 14px; color: var(--text-muted); }
.detail-tap-card-chev { flex: none; color: var(--text-subtle); font-size: 13px; }
/* Bottom-sheet form fields (shared by the log-note / stage / reminder / create sheets). */ /* Bottom-sheet form fields (shared by the log-note / stage / reminder / create sheets). */
.sheet-field { margin-bottom: 14px; } .sheet-field { margin-bottom: 14px; }
@@ -5060,7 +5076,7 @@
// DesktopRemindersPage); the snooze preset sheet offers +1/+3/+7/+14d. Create links a real // DesktopRemindersPage); the snooze preset sheet offers +1/+3/+7/+14d. Create links a real
// investor via the canonical grid picker (source_row_id → server-resolved investor_id); // investor via the canonical grid picker (source_row_id → server-resolved investor_id);
// PATCH still can't reassign the investor, so the edit sheet shows it read-only. // PATCH still can't reassign the investor, so the edit sheet shows it read-only.
const MobileReminders = ({ token, user, onShowToast }) => { const MobileReminders = ({ token, user, onShowToast, onOpenInGrid }) => {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -5289,7 +5305,14 @@
</button> </button>
</div> </div>
) : (form.investor_name ? ( ) : (form.investor_name ? (
<div className="fs-row"><span className="fs-row-label">Investor</span><span className="fs-row-value">{form.investor_name}</span></div> <>
<div className="fs-row"><span className="fs-row-label">Investor</span><span className="fs-row-value">{form.investor_name}</span></div>
{/* 8h Open-in-Grid deep-link — to the reminder's investor (investor_id →
source_row_id); hidden for a team task with no grid linkage. */}
{onOpenInGrid && editing && editing.source_row_id && (
<button className="org-open-link" style={{ margin: '6px 0 14px' }} onClick={() => onOpenInGrid(editing.source_row_id)}>Open investor in Grid </button>
)}
</>
) : null)} ) : null)}
{users.length > 0 && ( {users.length > 0 && (
<div className="sheet-field"> <div className="sheet-field">
@@ -5586,7 +5609,7 @@
// an email-copy pill + Log/Email actions + an Organization card (earmark · stage · committed · // an email-copy pill + Log/Email actions + an Organization card (earmark · stage · committed ·
// last-contact · last-note · open-in-Grid). committed/stage/last-contact come from the enriched // last-contact · last-note · open-in-Grid). committed/stage/last-contact come from the enriched
// list `contact`; the comms (for the last-note + the log refresh) come from /api/contacts/{id}. // list `contact`; the comms (for the last-note + the log refresh) come from /api/contacts/{id}.
const MobileContactDetail = ({ contact, token, onClose, onShowToast, onNavigate }) => { const MobileContactDetail = ({ contact, token, onClose, onShowToast, onOpenInGrid }) => {
const [details, setDetails] = useState(null); const [details, setDetails] = useState(null);
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [logOpen, setLogOpen] = useState(false); const [logOpen, setLogOpen] = useState(false);
@@ -5683,7 +5706,7 @@
<span className="org-card-note-summary">{lastNote.subject || lastNote.body || '—'}</span> <span className="org-card-note-summary">{lastNote.subject || lastNote.body || '—'}</span>
</div> </div>
)} )}
{onNavigate && <button className="org-open-link" onClick={() => { onNavigate('fundraising-grid'); close(); }}>Open investor in Grid </button>} {onOpenInGrid && contact.source_row_id && <button className="org-open-link" onClick={() => onOpenInGrid(contact.source_row_id)}>Open investor in Grid </button>}
</div> </div>
<LogCommunicationSheet open={logOpen} onClose={() => setLogOpen(false)} onSubmit={submitLog} busy={busy} forLabel={name} /> <LogCommunicationSheet open={logOpen} onClose={() => setLogOpen(false)} onSubmit={submitLog} busy={busy} forLabel={name} />
@@ -5691,7 +5714,7 @@
); );
}; };
const MobileContactsPage = ({ token, onShowToast, onNavigate }) => { const MobileContactsPage = ({ token, onShowToast, onOpenInGrid }) => {
const [contacts, setContacts] = useState([]); const [contacts, setContacts] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -5827,7 +5850,7 @@
token={token} token={token}
onClose={() => setSelected(null)} onClose={() => setSelected(null)}
onShowToast={onShowToast} onShowToast={onShowToast}
onNavigate={onNavigate} onOpenInGrid={onOpenInGrid}
/> />
)} )}
</div> </div>
@@ -6379,7 +6402,7 @@
// grid detail's stage edit — opp-centric (matches DesktopPipelinePage), read-only amounts. // grid detail's stage edit — opp-centric (matches DesktopPipelinePage), read-only amounts.
// Removal/deletion stays on the desktop board + the Grid detail's "remove from pipeline"; // Removal/deletion stays on the desktop board + the Grid detail's "remove from pipeline";
// the Pipeline tab is view + advance-stage only. // the Pipeline tab is view + advance-stage only.
const MobilePipeline = ({ token, onShowToast }) => { const MobilePipeline = ({ token, onShowToast, onOpenInGrid }) => {
const [opportunities, setOpportunities] = useState([]); const [opportunities, setOpportunities] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -6652,6 +6675,12 @@
</div> </div>
<NoteTimeline comms={comms} /> <NoteTimeline comms={comms} />
{/* 8h Open-in-Grid deep-link — to the deal's investor (durable
fundraising_investor_id → source_row_id); hidden for an unlinked deal. */}
{onOpenInGrid && opp.source_row_id && (
<button className="org-open-link" style={{ marginTop: '16px' }} onClick={() => onOpenInGrid(opp.source_row_id)}>Open investor in Grid </button>
)}
{dealParts && <div className="sheet-footnote">{dealParts}</div>} {dealParts && <div className="sheet-footnote">{dealParts}</div>}
<div className="sheet-footnote">Stage moves and logged communications both write the shared opportunities row — the same data the Grid edits. Amounts stay read-only on mobile.</div> <div className="sheet-footnote">Stage moves and logged communications both write the shared opportunities row — the same data the Grid edits. Amounts stay read-only on mobile.</div>
@@ -7716,7 +7745,11 @@
} : v))); } : v)));
onShowToast('View updated', 'success'); onShowToast('View updated', 'success');
if (onUiActionHandled) onUiActionHandled(); if (onUiActionHandled) onUiActionHandled();
return;
} }
// Drain any unrecognized action (e.g. the mobile-only object `open-investor` deep-link
// if the viewport crossed to desktop mid-flight) so it can't linger in shell state.
if (uiAction && onUiActionHandled) onUiActionHandled();
}, [uiAction, onUiActionHandled, setViews, activeView, filters, quickSearch, hiddenColumns, columnFilters, footerAggs, rowDensity, columnsForActiveView, onShowToast]); }, [uiAction, onUiActionHandled, setViews, activeView, filters, quickSearch, hiddenColumns, columnFilters, footerAggs, rowDensity, columnsForActiveView, onShowToast]);
const fundColumnIds = useMemo(() => columns.filter((c) => c.isFund).map((c) => c.id), [columns]); const fundColumnIds = useMemo(() => columns.filter((c) => c.isFund).map((c) => c.id), [columns]);
@@ -10069,7 +10102,7 @@
// reminders), NEVER the whole-grid PUT (BRIEF §3a — that would race the 5-person live grid). // reminders), NEVER the whole-grid PUT (BRIEF §3a — that would race the 5-person live grid).
// Editable here: create investor, edit name + contact pills (P3b, via update-row), log a // Editable here: create investor, edit name + contact pills (P3b, via update-row), log a
// note, pipeline stage, set a reminder. Money amounts stay desktop-only (read-only here). // note, pipeline stage, set a reminder. Money amounts stay desktop-only (read-only here).
const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView }) => { const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView, uiAction, onUiActionHandled }) => {
const [columns, setColumns] = useState([]); const [columns, setColumns] = useState([]);
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -10085,6 +10118,13 @@
// (source_row_id → canonical contacts → communications); commsReload re-runs it after a log. // (source_row_id → canonical contacts → communications); commsReload re-runs it after a log.
const [comms, setComms] = useState([]); const [comms, setComms] = useState([]);
const [commsReload, setCommsReload] = useState(0); const [commsReload, setCommsReload] = useState(0);
// G5 — the open investor's soonest active reminder (title + due) for the dedicated
// Reminder card. Fetched on open (source_row_id → investor) like the comms timeline;
// reminderReload re-runs it after a set/edit. Tri-state: `undefined` = still loading
// (card is disabled so a tap can't open the sheet with stale/empty data and POST a
// duplicate), `null` = loaded/none ("No reminder set"), object = the reminder to edit.
const [reminder, setReminder] = useState(null);
const [reminderReload, setReminderReload] = useState(0);
// P3b edit sheet: investor name + the full contacts array (pills carry their other // P3b edit sheet: investor name + the full contacts array (pills carry their other
// fields — title/location/linkedin — through unedited; we only surface name/email/title). // fields — title/location/linkedin — through unedited; we only surface name/email/title).
const [editForm, setEditForm] = useState({ name: '', contacts: [] }); const [editForm, setEditForm] = useState({ name: '', contacts: [] });
@@ -10121,6 +10161,33 @@
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [selectedId, token, commsReload]); }, [selectedId, token, commsReload]);
// G5 reminder fetch — soonest active reminder for the open investor (API orders by
// due_date ASC). Same cancel-guard + reload-key pattern as the comms timeline.
useEffect(() => {
if (!selectedId) { setReminder(null); return undefined; }
let cancelled = false;
setReminder(undefined); // mark loading until this investor's fetch resolves
(async () => {
try {
const r = await api(`/api/reminders?source_row_id=${encodeURIComponent(selectedId)}&status=active`, {}, token);
const list = Array.isArray(r.data) ? r.data : [];
if (!cancelled) setReminder(list[0] || null);
} catch (_) { if (!cancelled) setReminder(null); }
})();
return () => { cancelled = true; };
}, [selectedId, token, reminderReload]);
// Open-in-Grid deep-link (8h) — the shell sets a one-shot {type:'open-investor', rowId}
// action when "Open investor in Grid" is tapped from the Contacts detail. Open that
// investor's detail and clear the action. The row may not be loaded yet (the action can
// arrive before reload completes) — selectedId resolves to selectedRow once rows land.
useEffect(() => {
if (uiAction && typeof uiAction === 'object' && uiAction.type === 'open-investor' && uiAction.rowId) {
setSelectedId(uiAction.rowId);
if (onUiActionHandled) onUiActionHandled();
}
}, [uiAction, onUiActionHandled]);
const fundColumnIds = useMemo(() => columns.filter((c) => c && c.isFund).map((c) => c.id), [columns]); const fundColumnIds = useMemo(() => columns.filter((c) => c && c.isFund).map((c) => c.id), [columns]);
const fundColumns = useMemo(() => columns.filter((c) => c && c.isFund), [columns]); const fundColumns = useMemo(() => columns.filter((c) => c && c.isFund), [columns]);
const activeViewObj = useMemo(() => views.find((v) => v.id === activeView) || null, [views, activeView]); const activeViewObj = useMemo(() => views.find((v) => v.id === activeView) || null, [views, activeView]);
@@ -10228,13 +10295,23 @@
if (!String(reminderForm.title || '').trim()) { onShowToast('A reminder needs a title', 'error'); return; } if (!String(reminderForm.title || '').trim()) { onShowToast('A reminder needs a title', 'error'); return; }
setBusy(true); setBusy(true);
try { try {
await api('/api/reminders', { method: 'POST', body: JSON.stringify({ if (reminder && reminder.id) {
source_row_id: row.id, investor_name: row.investor_name || '', // Editing the existing reminder — PATCH it in place (can't reassign the
title: reminderForm.title.trim(), due_date: reminderForm.due_date || '', details: reminderForm.details || '', // investor via PATCH, which is fine: the card always targets this investor).
}) }, token); await api(`/api/reminders/${reminder.id}`, { method: 'PATCH', body: JSON.stringify({
onShowToast('Reminder set', 'success'); title: reminderForm.title.trim(), due_date: reminderForm.due_date || '', details: reminderForm.details || '',
}) }, token);
onShowToast('Reminder updated', 'success');
} else {
await api('/api/reminders', { method: 'POST', body: JSON.stringify({
source_row_id: row.id, investor_name: row.investor_name || '',
title: reminderForm.title.trim(), due_date: reminderForm.due_date || '', details: reminderForm.details || '',
}) }, token);
onShowToast('Reminder set', 'success');
}
setReminderForm({ title: '', due_date: '', details: '' }); setReminderForm({ title: '', due_date: '', details: '' });
closeSheet(); closeSheet();
setReminderReload((n) => n + 1);
await reload(true); await reload(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to set reminder'), 'error'); } } catch (err) { onShowToast(getErrorMessage(err, 'Failed to set reminder'), 'error'); }
finally { setBusy(false); } finally { setBusy(false); }
@@ -10492,17 +10569,16 @@
</span> </span>
</div> </div>
{/* G4 — pipeline stage as a single tappable card (dc GridApp:198-207):
stage chip inline (or "Not in pipeline") + chevron → stage sheet. */}
<div className="fs-section"> <div className="fs-section">
<div className="fs-section-label">Pipeline</div> <div className="fs-section-label">Pipeline stage</div>
<div className="fs-row"> <button className="detail-tap-card" onClick={() => setSheet('stage')}>
<span className="fs-row-label">Stage</span> <span className="detail-tap-card-left">
<span className="fs-row-value"> {row.pipeline ? <StageChip stage={row.pipeline_stage} /> : <span className="detail-tap-card-empty">Not in pipeline</span>}
{row.pipeline ? <StageChip stage={row.pipeline_stage} /> : <span style={{ color: 'var(--text-subtle)' }}>Not in pipeline</span>}
</span> </span>
</div> <span className="detail-tap-card-chev">{row.pipeline ? 'Change ' : 'Add '}</span>
<div className="fs-action-row" style={{ marginTop: '10px' }}> </button>
<button className="fs-action-btn" onClick={() => setSheet('stage')}>{row.pipeline ? 'Change stage' : 'Add to pipeline'}</button>
</div>
</div> </div>
<div className="fs-section"> <div className="fs-section">
@@ -10532,12 +10608,30 @@
{days == null ? 'No activity' : (formatAgeShort(days) + (days <= 0 ? '' : ' ago'))}{row.staleness === 'stale' ? ' · stale' : ''} {days == null ? 'No activity' : (formatAgeShort(days) + (days <= 0 ? '' : ' ago'))}{row.staleness === 'stale' ? ' · stale' : ''}
</span> </span>
</div> </div>
{row.reminder_status && ( </div>
<div className="fs-row"><span className="fs-row-label">Reminder</span><span className="fs-row-value">{String(row.reminder_status).replace('_', ' ')}</span></div>
)} {/* G5 — dedicated Reminder card (dc GridApp:249-262): the soonest active
<div className="fs-action-row" style={{ marginTop: '12px' }}> reminder (title + urgency due-chip) or "No reminder set"; tap to set/edit. */}
<button className="fs-action-btn" onClick={() => { setReminderForm({ title: '', due_date: '', details: '' }); setSheet('reminder'); }}>Set a reminder</button> <div className="fs-section">
</div> <div className="fs-section-label">Reminder</div>
<button className="detail-tap-card" disabled={reminder === undefined} onClick={() => {
setReminderForm(reminder
? { title: reminder.title || '', due_date: (reminder.due_date || '').slice(0, 10), details: reminder.details || '' }
: { title: '', due_date: '', details: '' });
setSheet('reminder');
}}>
{reminder === undefined ? (
<span className="detail-tap-card-empty">Checking reminders…</span>
) : reminder ? (
<span className="detail-tap-card-col">
<span className="detail-tap-card-note">{reminder.title}</span>
<DueChip iso={reminder.due_date} />
</span>
) : (
<span className="detail-tap-card-empty">No reminder set</span>
)}
{reminder !== undefined && <span className="detail-tap-card-chev"></span>}
</button>
</div> </div>
{/* G6 — notes/communication timeline (dc GridApp:265-290): dot-and-line rail {/* G6 — notes/communication timeline (dc GridApp:265-290): dot-and-line rail
@@ -10563,7 +10657,7 @@
{row.pipeline && <button className="sheet-remove" onClick={removePipeline} disabled={busy}>Remove from pipeline</button>} {row.pipeline && <button className="sheet-remove" onClick={removePipeline} disabled={busy}>Remove from pipeline</button>}
</BottomSheet> </BottomSheet>
<BottomSheet open={sheet === 'reminder'} onClose={closeSheet} title="Set a reminder"> <BottomSheet open={sheet === 'reminder'} onClose={closeSheet} title={reminder ? 'Edit reminder' : 'Set a reminder'}>
<div className="sheet-field"> <div className="sheet-field">
<label className="sheet-field-label">Title</label> <label className="sheet-field-label">Title</label>
<input className="sheet-input" value={reminderForm.title} onChange={(e) => setReminderForm((f) => ({ ...f, title: e.target.value }))} placeholder="e.g. Follow up on Fund III" /> <input className="sheet-input" value={reminderForm.title} onChange={(e) => setReminderForm((f) => ({ ...f, title: e.target.value }))} placeholder="e.g. Follow up on Fund III" />
@@ -10576,7 +10670,7 @@
<label className="sheet-field-label">Details (optional)</label> <label className="sheet-field-label">Details (optional)</label>
<textarea className="sheet-textarea" value={reminderForm.details} onChange={(e) => setReminderForm((f) => ({ ...f, details: e.target.value }))} /> <textarea className="sheet-textarea" value={reminderForm.details} onChange={(e) => setReminderForm((f) => ({ ...f, details: e.target.value }))} />
</div> </div>
<button className="sheet-submit" onClick={submitReminder} disabled={busy}>{busy ? 'Saving…' : 'Set reminder'}</button> <button className="sheet-submit" onClick={submitReminder} disabled={busy}>{busy ? 'Saving…' : (reminder ? 'Update reminder' : 'Set reminder')}</button>
</BottomSheet> </BottomSheet>
<BottomSheet open={sheet === 'edit'} onClose={closeSheet} title="Edit investor"> <BottomSheet open={sheet === 'edit'} onClose={closeSheet} title="Edit investor">
@@ -14137,6 +14231,13 @@
const [gridViews, setGridViews] = useState(loadGridViews()); const [gridViews, setGridViews] = useState(loadGridViews());
const [activeGridView, setActiveGridView] = useState('view-main'); const [activeGridView, setActiveGridView] = useState('view-main');
const [gridUiAction, setGridUiAction] = useState(null); const [gridUiAction, setGridUiAction] = useState(null);
// Open-in-Grid deep-link (8h) — shared by the Contacts, Pipeline, and Reminders detail
// surfaces. Sets a one-shot grid action (an object the desktop grid's string-equality
// branches ignore) and switches to the grid, which opens that investor's detail on mount.
const openInvestorInGrid = (rowId) => {
if (rowId) setGridUiAction({ type: 'open-investor', rowId });
setPage('fundraising-grid');
};
const [sidebarContextMenu, setSidebarContextMenu] = useState(null); const [sidebarContextMenu, setSidebarContextMenu] = useState(null);
const [draggingViewId, setDraggingViewId] = useState(null); const [draggingViewId, setDraggingViewId] = useState(null);
const [dragOverViewId, setDragOverViewId] = useState(null); const [dragOverViewId, setDragOverViewId] = useState(null);
@@ -14439,9 +14540,9 @@
/> />
)} )}
{page === 'dashboard' && <DashboardPage token={token} />} {page === 'dashboard' && <DashboardPage token={token} />}
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} onNavigate={setPage} />} {page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />} {page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
{page === 'reminders' && <RemindersPage token={token} user={user} onShowToast={showToast} />} {page === 'reminders' && <RemindersPage token={token} user={user} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
{page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />} {page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />}
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />} {page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />} {page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}