Mobile Phase 8c: Grid-detail notes timeline + top-bar quick-log pencil

Grid detail (G6): replace the single row.notes blob with a NoteTimeline fed by
a new investor-level read, GET /api/communications?source_row_id=<grid row id>
(filter maps the grid row -> fundraising_investors.source_row_id ->
fundraising_contacts.contact_id -> communications, soft-delete-respecting;
cancelled-flag fetch + commsReload after a log). Note-logging now uses the
shared LogCommunicationSheet, retiring the bespoke noteForm select sheet and the
dead .fs-note-log style.

New MobileQuickLog: a shell mobile-top-bar pencil reachable from every tab —
two-step sheet (pick investor via search + recent-first pool -> inline log form)
writing through the one-row /api/fundraising/log-communication path.

source_row_id and contact_id are kept mutually exclusive in
handle_list_communications so a future caller passing both can't get the empty
intersection. Guarded by test_grid_comm_timeline.py (cross-contact aggregation,
investor isolation, soft-delete); 39/39 backend green.
This commit is contained in:
Keysat
2026-06-19 21:43:05 -05:00
parent e57b154a6d
commit 93ac0c240f
4 changed files with 373 additions and 32 deletions
+191 -29
View File
@@ -2472,6 +2472,37 @@
}
.log-type-btn.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-light); }
/* Quick-log — top-bar pencil button + two-step picker sheet (dc GridApp:53-55). */
.quicklog-btn {
width: 36px; height: 36px; border-radius: 50%;
border: 1px solid var(--border); background: var(--bg-panel-elevated);
color: var(--text-muted); cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.quicklog-btn:active { background: var(--bg-hover); }
.quicklog-hint { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin: 0 0 12px; }
.quicklog-pool { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.quicklog-empty { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; }
.quicklog-row {
width: 100%; text-align: left; cursor: pointer; font-family: inherit;
background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px;
padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 10px;
color: var(--text-primary);
}
.quicklog-row:active { background: var(--bg-hover); }
.quicklog-row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.quicklog-row-name { font-size: 15px; font-weight: 500; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.quicklog-row-sub { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.quicklog-target {
display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px;
background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px; padding: 11px 13px;
}
.quicklog-target-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.quicklog-target-label { font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); }
.quicklog-target-name { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.quicklog-change { flex: none; background: none; border: none; color: var(--accent-light); font-size: 13px; cursor: pointer; font-family: inherit; }
.quicklog-warn { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin-bottom: 14px; }
/* Full-screen detail: read-only sections + edit-entry buttons. */
.fs-detail-chips { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.fs-action-row { display: flex; gap: 8px; flex-wrap: wrap; }
@@ -2485,7 +2516,6 @@
.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; }
.fs-note-log { white-space: pre-wrap; font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
/* Bottom-sheet form fields (shared by the log-note / stage / reminder / create sheets). */
.sheet-field { margin-bottom: 14px; }
@@ -9732,8 +9762,11 @@
const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder'
const [busy, setBusy] = useState(false);
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
const [noteForm, setNoteForm] = useState({ type: 'note', subject: '', body: '' });
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
// G6 — investor-level communications timeline for the open detail. Fetched on open
// (source_row_id → canonical contacts → communications); commsReload re-runs it after a log.
const [comms, setComms] = useState([]);
const [commsReload, setCommsReload] = 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: [] });
@@ -9755,6 +9788,21 @@
useEffect(() => { reload(); }, [reload]);
// G6 timeline fetch — keyed on the open investor (+ commsReload after a log). A cancelled
// flag drops a stale response so opening row A then B can't leave B's detail showing A's
// notes (the 8b reviewer race-guard pattern). Non-fatal: timeline stays empty on failure.
useEffect(() => {
if (!selectedId) { setComms([]); return undefined; }
let cancelled = false;
(async () => {
try {
const r = await api(`/api/communications?source_row_id=${encodeURIComponent(selectedId)}&limit=50`, {}, token);
if (!cancelled) setComms(Array.isArray(r.data) ? r.data : []);
} catch (_) { if (!cancelled) setComms([]); }
})();
return () => { cancelled = true; };
}, [selectedId, token, commsReload]);
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]);
@@ -9777,20 +9825,22 @@
const closeSheet = () => setSheet(null);
// ── writes (targeted one-row endpoints only) ──
const submitNote = async () => {
// Log a communication against the open investor's first contact via the one-row grid path
// (creates a communications row + appends the notes blob). Refreshes the G6 timeline +
// card recency. Payload shape matches the shared LogCommunicationSheet ({type,subject,body}).
const submitNote = async ({ type, subject, body }) => {
const row = selectedRow; if (!row) return;
const contact = (Array.isArray(row.contacts) && row.contacts[0]) || null;
if (!contact) { onShowToast('This investor has no contact yet — add one on desktop first', 'error'); return; }
if (!String(noteForm.body || noteForm.subject || '').trim()) { onShowToast('Add a note', 'error'); return; }
setBusy(true);
try {
await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify({
row_id: row.id, investor_name: row.investor_name || '', contact,
type: noteForm.type || 'note', subject: noteForm.subject || '', body: noteForm.body || '', append_note: true,
type: type || 'note', subject: subject || '', body: body || '', append_note: true,
}) }, token);
onShowToast('Note logged', 'success');
setNoteForm({ type: 'note', subject: '', body: '' });
onShowToast('Communication logged', 'success');
closeSheet();
setCommsReload((n) => n + 1);
await reload(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to log note'), 'error'); }
finally { setBusy(false); }
@@ -10084,34 +10134,23 @@
{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>
)}
{row.notes && <div className="fs-note-log" style={{ marginTop: '10px' }}>{row.notes}</div>}
<div className="fs-action-row" style={{ marginTop: '12px' }}>
<button className="fs-action-btn" onClick={() => { setNoteForm({ type: 'note', subject: '', body: '' }); setSheet('note'); }}>Log a note</button>
<button className="fs-action-btn" onClick={() => { setReminderForm({ title: '', due_date: '', details: '' }); setSheet('reminder'); }}>Set a reminder</button>
</div>
</div>
{/* G6 — notes/communication timeline (dc GridApp:265-290): dot-and-line rail
of the investor's logged communications, "+ Log" via the shared sheet. */}
<div className="fs-section">
<div className="sheet-section-head" style={{ margin: '0 0 10px' }}>
<span className="fs-section-label" style={{ margin: 0 }}>Notes / communication</span>
<button className="sheet-log-btn" onClick={() => setSheet('note')}>+ Log</button>
</div>
<NoteTimeline comms={comms} />
</div>
</div>
<BottomSheet open={sheet === 'note'} onClose={closeSheet} title="Log a note">
<div className="sheet-field">
<label className="sheet-field-label">Type</label>
<select className="sheet-select" value={noteForm.type} onChange={(e) => setNoteForm((f) => ({ ...f, type: e.target.value }))}>
<option value="note">Note</option>
<option value="call">Call</option>
<option value="email">Email</option>
<option value="meeting">Meeting</option>
</select>
</div>
<div className="sheet-field">
<label className="sheet-field-label">Summary (optional)</label>
<input className="sheet-input" value={noteForm.subject} onChange={(e) => setNoteForm((f) => ({ ...f, subject: e.target.value }))} placeholder="e.g. Intro call" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Note</label>
<textarea className="sheet-textarea" value={noteForm.body} onChange={(e) => setNoteForm((f) => ({ ...f, body: e.target.value }))} placeholder="What happened…" />
</div>
<button className="sheet-submit" onClick={submitNote} disabled={busy}>{busy ? 'Saving…' : 'Log note'}</button>
</BottomSheet>
<LogCommunicationSheet open={sheet === 'note'} onClose={closeSheet} onSubmit={submitNote} busy={busy} forLabel={row.investor_name || 'this investor'} />
<BottomSheet open={sheet === 'stage'} onClose={closeSheet} title="Pipeline stage">
{PIPELINE_STAGES.map((st) => (
@@ -13555,6 +13594,128 @@
);
};
// Quick-log — the dc top-bar pencil (GridApp:53-55): log a communication against any investor
// without first opening its detail. Two steps: pick an investor (search + recent-first pool) →
// inline log form. Writes via the one-row /api/fundraising/log-communication path (same write
// the Grid detail uses; resolves the investor's first contact server-side). Lives in the shell
// mobile top bar so it's reachable from every mobile tab. Investors are fetched lazily on open.
const MobileQuickLog = ({ token, onShowToast }) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [rows, setRows] = useState([]);
const [search, setSearch] = useState('');
const [targetId, setTargetId] = useState(null);
const [form, setForm] = useState({ type: 'note', subject: '', body: '' });
const [busy, setBusy] = useState(false);
const openSheet = async () => {
if (open) return; // already open — don't reset state or re-fetch on a second tap
setTargetId(null); setSearch(''); setForm({ type: 'note', subject: '', body: '' });
setBusy(false); setOpen(true); setLoading(true);
try {
const result = await api('/api/fundraising/state', {}, token);
const grid = (result && result.data && result.data.grid) || {};
setRows(Array.isArray(grid.rows) ? grid.rows : []);
} catch (_) { onShowToast('Failed to load investors', 'error'); }
finally { setLoading(false); }
};
const closeSheet = () => setOpen(false);
// Recent-first pool (most recently active surface without searching; no-activity last),
// filtered by investor name or any contact name/email, capped at 8 like the dc.
const pool = useMemo(() => {
const q = search.trim().toLowerCase();
const base = rows.filter((r) => r && typeof r === 'object');
const matched = !q ? base : base.filter((r) => {
const contactText = (r.contacts || []).map((c) => `${c.name || ''} ${c.email || ''}`).join(' ');
return `${r.investor_name || ''} ${contactText}`.toLowerCase().includes(q);
});
return [...matched]
.sort((a, b) => String(b.last_activity_at || '').localeCompare(String(a.last_activity_at || '')))
.slice(0, 8);
}, [rows, search]);
const target = useMemo(() => rows.find((r) => r.id === targetId) || null, [rows, targetId]);
const targetContact = (target && Array.isArray(target.contacts) && target.contacts[0]) || null;
const canSubmit = !busy && targetContact && (form.subject.trim() || form.body.trim());
const submit = async () => {
if (!target || !canSubmit) return;
setBusy(true);
try {
await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify({
row_id: target.id, investor_name: target.investor_name || '', contact: targetContact,
type: form.type || 'note', subject: form.subject.trim(), body: form.body.trim(), append_note: true,
}) }, token);
onShowToast(`Logged for ${target.investor_name || 'investor'}`, 'success');
closeSheet();
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to log communication'), 'error'); }
finally { setBusy(false); }
};
return (
<>
<button className="quicklog-btn" onClick={openSheet} aria-label="Log communication" title="Log communication">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</button>
<BottomSheet open={open} onClose={closeSheet} title="Log communication">
{!target ? (
<>
<div className="quicklog-hint">Pick an investor, then log the communication.</div>
<input className="sheet-input" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search investor or contact…" />
<div className="quicklog-pool">
{loading ? <div className="quicklog-empty">Loading…</div>
: pool.length === 0 ? <div className="quicklog-empty">No matches.</div>
: pool.map((r) => {
const ct = (Array.isArray(r.contacts) && r.contacts[0]) || null;
const more = (r.contacts || []).length - 1;
const sub = ct ? `${ct.name || ct.email || 'contact'}${more > 0 ? ` +${more}` : ''}` : 'No contacts';
return (
<button key={r.id} className="quicklog-row" onClick={() => setTargetId(r.id)}>
<span className="quicklog-row-main">
<span className="quicklog-row-name">{r.investor_name || 'Unnamed investor'}</span>
<span className="quicklog-row-sub">{sub}</span>
</span>
{r.pipeline && <StageChip stage={r.pipeline_stage} sm />}
</button>
);
})}
</div>
</>
) : (
<>
<div className="quicklog-target">
<span className="quicklog-target-main">
<span className="quicklog-target-label">Logging for</span>
<span className="quicklog-target-name">{target.investor_name || 'Investor'}</span>
</span>
<button type="button" className="quicklog-change" onClick={() => setTargetId(null)}>Change</button>
</div>
{!targetContact && <div className="quicklog-warn">This investor has no contact yet — add one on desktop before logging.</div>}
<label className="sheet-field-label">Type</label>
<div className="log-type-row">
{LOG_TYPES.map((t) => (
<button key={t} type="button" className={`log-type-btn ${form.type === t ? 'active' : ''}`} onClick={() => setForm((f) => ({ ...f, type: t }))}>{t}</button>
))}
</div>
<div className="sheet-field" style={{ marginTop: '16px' }}>
<label className="sheet-field-label">Summary</label>
<input className="sheet-input" value={form.subject} onChange={(e) => setForm((f) => ({ ...f, subject: e.target.value }))} placeholder="Short headline" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Details</label>
<textarea className="sheet-textarea" value={form.body} onChange={(e) => setForm((f) => ({ ...f, body: e.target.value }))} placeholder="Full context kept in communications history" />
</div>
<button className="sheet-submit" onClick={submit} disabled={!canSubmit}>{busy ? 'Logging…' : 'Log communication'}</button>
</>
)}
</BottomSheet>
</>
);
};
const App = () => {
const { token, user, logout } = useAuth();
const [page, setPage] = useState('fundraising-grid');
@@ -13843,6 +14004,7 @@
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
</div>
<div className="mobile-only" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<MobileQuickLog token={token} onShowToast={showToast} />
<ThemeToggle theme={theme} onToggle={toggleTheme} variant="icon" />
<div style={{ position: 'relative' }}>
<button className="account-btn" onClick={() => setAccountMenuOpen((o) => !o)} aria-label="Account menu">