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",
|
||||
|
||||
@@ -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),
|
||||
or null when the investor isn't in the pipeline.
|
||||
- `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.
|
||||
|
||||
Run: cd backend && python3 test_contacts_grid_signals.py
|
||||
@@ -148,6 +151,15 @@ def main():
|
||||
check((vince or {}).get("priority") is False,
|
||||
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) ──
|
||||
print("\n[get-by-id: /api/contacts/{id} also injects committed + pipeline_stage]")
|
||||
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.
|
||||
check(jd.get("priority") is False,
|
||||
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:
|
||||
httpd.shutdown()
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ def main():
|
||||
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")
|
||||
opp_id = opp.get("id")
|
||||
jane_contact_id = opp.get("contact_id")
|
||||
|
||||
# ── idempotent re-link: returns existing, board-owned stage NOT reseeded ──
|
||||
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)
|
||||
ids = [o["id"] for o in (d or {}).get("data", [])]
|
||||
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)
|
||||
active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities")
|
||||
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")
|
||||
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")
|
||||
|
||||
# ── 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:
|
||||
httpd.shutdown()
|
||||
|
||||
|
||||
@@ -158,6 +158,13 @@ def main():
|
||||
items = (d or {}).get("data", [])
|
||||
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")
|
||||
# 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
|
||||
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
@@ -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-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; }
|
||||
/* 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). */
|
||||
.sheet-field { margin-bottom: 14px; }
|
||||
@@ -5060,7 +5076,7 @@
|
||||
// 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);
|
||||
// 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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -5289,7 +5305,14 @@
|
||||
</button>
|
||||
</div>
|
||||
) : (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)}
|
||||
{users.length > 0 && (
|
||||
<div className="sheet-field">
|
||||
@@ -5586,7 +5609,7 @@
|
||||
// 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
|
||||
// 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 [open, setOpen] = useState(true);
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
@@ -5683,7 +5706,7 @@
|
||||
<span className="org-card-note-summary">{lastNote.subject || lastNote.body || '—'}</span>
|
||||
</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>
|
||||
|
||||
<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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -5827,7 +5850,7 @@
|
||||
token={token}
|
||||
onClose={() => setSelected(null)}
|
||||
onShowToast={onShowToast}
|
||||
onNavigate={onNavigate}
|
||||
onOpenInGrid={onOpenInGrid}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -6379,7 +6402,7 @@
|
||||
// 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";
|
||||
// the Pipeline tab is view + advance-stage only.
|
||||
const MobilePipeline = ({ token, onShowToast }) => {
|
||||
const MobilePipeline = ({ token, onShowToast, onOpenInGrid }) => {
|
||||
const [opportunities, setOpportunities] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -6652,6 +6675,12 @@
|
||||
</div>
|
||||
<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>}
|
||||
<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)));
|
||||
onShowToast('View updated', 'success');
|
||||
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]);
|
||||
|
||||
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).
|
||||
// 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).
|
||||
const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView }) => {
|
||||
const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView, uiAction, onUiActionHandled }) => {
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -10085,6 +10118,13 @@
|
||||
// (source_row_id → canonical contacts → communications); commsReload re-runs it after a log.
|
||||
const [comms, setComms] = useState([]);
|
||||
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
|
||||
// fields — title/location/linkedin — through unedited; we only surface name/email/title).
|
||||
const [editForm, setEditForm] = useState({ name: '', contacts: [] });
|
||||
@@ -10121,6 +10161,33 @@
|
||||
return () => { cancelled = true; };
|
||||
}, [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 fundColumns = useMemo(() => columns.filter((c) => c && c.isFund), [columns]);
|
||||
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; }
|
||||
setBusy(true);
|
||||
try {
|
||||
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');
|
||||
if (reminder && reminder.id) {
|
||||
// Editing the existing reminder — PATCH it in place (can't reassign the
|
||||
// investor via PATCH, which is fine: the card always targets this investor).
|
||||
await api(`/api/reminders/${reminder.id}`, { method: 'PATCH', body: JSON.stringify({
|
||||
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: '' });
|
||||
closeSheet();
|
||||
setReminderReload((n) => n + 1);
|
||||
await reload(true);
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to set reminder'), 'error'); }
|
||||
finally { setBusy(false); }
|
||||
@@ -10492,17 +10569,16 @@
|
||||
</span>
|
||||
</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-label">Pipeline</div>
|
||||
<div className="fs-row">
|
||||
<span className="fs-row-label">Stage</span>
|
||||
<span className="fs-row-value">
|
||||
{row.pipeline ? <StageChip stage={row.pipeline_stage} /> : <span style={{ color: 'var(--text-subtle)' }}>Not in pipeline</span>}
|
||||
<div className="fs-section-label">Pipeline stage</div>
|
||||
<button className="detail-tap-card" onClick={() => setSheet('stage')}>
|
||||
<span className="detail-tap-card-left">
|
||||
{row.pipeline ? <StageChip stage={row.pipeline_stage} /> : <span className="detail-tap-card-empty">Not in pipeline</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="fs-action-row" style={{ marginTop: '10px' }}>
|
||||
<button className="fs-action-btn" onClick={() => setSheet('stage')}>{row.pipeline ? 'Change stage' : 'Add to pipeline'}</button>
|
||||
</div>
|
||||
<span className="detail-tap-card-chev">{row.pipeline ? 'Change ›' : 'Add ›'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="fs-section">
|
||||
@@ -10532,12 +10608,30 @@
|
||||
{days == null ? 'No activity' : (formatAgeShort(days) + (days <= 0 ? '' : ' ago'))}{row.staleness === 'stale' ? ' · stale' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{row.reminder_status && (
|
||||
<div className="fs-row"><span className="fs-row-label">Reminder</span><span className="fs-row-value">{String(row.reminder_status).replace('_', ' ')}</span></div>
|
||||
)}
|
||||
<div className="fs-action-row" style={{ marginTop: '12px' }}>
|
||||
<button className="fs-action-btn" onClick={() => { setReminderForm({ title: '', due_date: '', details: '' }); setSheet('reminder'); }}>Set a reminder</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* G5 — dedicated Reminder card (dc GridApp:249-262): the soonest active
|
||||
reminder (title + urgency due-chip) or "No reminder set"; tap to set/edit. */}
|
||||
<div className="fs-section">
|
||||
<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>
|
||||
|
||||
{/* 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>}
|
||||
</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">
|
||||
<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" />
|
||||
@@ -10576,7 +10670,7 @@
|
||||
<label className="sheet-field-label">Details (optional)</label>
|
||||
<textarea className="sheet-textarea" value={reminderForm.details} onChange={(e) => setReminderForm((f) => ({ ...f, details: e.target.value }))} />
|
||||
</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 open={sheet === 'edit'} onClose={closeSheet} title="Edit investor">
|
||||
@@ -14137,6 +14231,13 @@
|
||||
const [gridViews, setGridViews] = useState(loadGridViews());
|
||||
const [activeGridView, setActiveGridView] = useState('view-main');
|
||||
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 [draggingViewId, setDraggingViewId] = useState(null);
|
||||
const [dragOverViewId, setDragOverViewId] = useState(null);
|
||||
@@ -14439,9 +14540,9 @@
|
||||
/>
|
||||
)}
|
||||
{page === 'dashboard' && <DashboardPage token={token} />}
|
||||
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} onNavigate={setPage} />}
|
||||
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
||||
{page === 'reminders' && <RemindersPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
|
||||
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
|
||||
{page === 'reminders' && <RemindersPage token={token} user={user} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
|
||||
{page === 'communications' && <CommunicationsPage 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} />}
|
||||
|
||||
Reference in New Issue
Block a user