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:
+191
-29
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user