Mobile Phase 8g: add-investor sheet — optional stage picker + Priority toggle + reminder

The mobile "New investor" sheet now captures three optional fields beyond name/contact/note,
matching the dc (GridApp.dc.html:737):

- Initial pipeline stage — a .stage-pick chip picker, defaulting to "Not in pipeline" so a
  plain directory add never auto-creates an opportunity row (Grant's call).
- A framed "Flag as Priority" toggle.
- An optional reminder (title + a progressive due-date field).

submitCreate orchestrates one-row calls in order: create (log-communication
create_investor_if_missing, now carrying priority) -> if a stage was picked, link to the
pipeline at that stage (reusing applyStage's idempotent link-then-PATCH) -> if a reminder
title was given, POST /api/reminders keyed on the new row's source_row_id. The link and
reminder steps are non-fatal: a failure toasts but never loses the created investor, and a
create that returns no row id warns instead of a clean success.

Backend: handle_log_fundraising_communication honors an optional priority flag only on its
create-if-missing branch (an existing-row log never touches priority).

Guarded by test_grid_add_investor.py (priority-on-create, defaults-False, the create-branch-
only invariant, and the create->link / create->reminder handshakes on a freshly-synced row).
40/40 backend green; the create sheet was interaction-verified in a throwaway jsdom harness.
This commit is contained in:
Keysat
2026-06-20 06:15:16 -05:00
parent e53a41ae80
commit abc614fc98
4 changed files with 279 additions and 11 deletions
+90 -6
View File
@@ -2567,6 +2567,26 @@
.dedup-box-title { font-size: 12px; color: var(--due-soon, #e0b341); margin-bottom: 4px; }
.dedup-match { font-size: 13px; color: var(--text-secondary); padding: 3px 0; }
/* 8g add-investor: optional initial-stage chip picker + framed Priority toggle (dc GridApp:737).
Stage tint comes from <StageChip>; the framed button just carries the selection ring. */
.stage-pick { display: flex; flex-wrap: wrap; gap: 8px; }
.stage-pick-btn {
flex: 0 0 auto; min-height: var(--mobile-touch-target); padding: 6px 12px;
display: inline-flex; align-items: center; justify-content: center;
background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
color: var(--text-secondary); font-size: 13px; font-family: inherit; cursor: pointer;
}
/* StageChip carries its own fill, so lean on a clear accent ring (not just bg) for the selected state. */
.stage-pick-btn.active { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent); }
.sheet-toggle-opt {
width: 100%; min-height: var(--mobile-touch-target); gap: 12px; padding: 0 14px;
display: flex; align-items: center; justify-content: space-between;
background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
color: var(--text-primary); font-size: 14px; font-family: inherit; cursor: pointer;
}
.sheet-toggle-opt.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-light); }
.sheet-toggle-opt .check { flex: none; width: 16px; text-align: center; color: var(--accent); font-size: 15px; }
/* P3b — mobile contact-pill editor (the 'edit' sheet: investor name + add/edit/remove pills). */
.fs-detail-edit { margin-left: auto; background: transparent; border: none; color: var(--accent); font-size: 15px; font-family: inherit; cursor: pointer; padding: 6px 0 6px 8px; }
.pill-edit { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 10px 12px; margin-bottom: 10px; }
@@ -10059,7 +10079,7 @@
const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder' | 'sort'
const [sortKey, setSortKey] = useState('name'); // GRID_SORTS
const [busy, setBusy] = useState(false);
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' });
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.
@@ -10235,15 +10255,50 @@
try {
// The one-row create path: log-communication finds-or-creates the investor + first
// contact (no whole-grid PUT). append_note only if a first note was given (else the
// create just seeds name + contact).
// create just seeds name + contact). priority is honored on the create branch (8g).
const hasNote = !!String(createForm.note || '').trim();
await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify({
const resp = await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify({
investor_name: name, create_investor_if_missing: true,
contact: { name: cName, email: createForm.contactEmail.trim() },
type: 'note', body: createForm.note || '', append_note: hasNote,
priority: !!createForm.priority,
}) }, token);
onShowToast('Investor added', 'success');
setCreateForm({ name: '', contactName: '', contactEmail: '', note: '' });
const newRowId = resp && resp.data && resp.data.row && resp.data.row.id;
// Optional: drop the brand-new investor into the pipeline at the chosen stage (8g).
// Reuses the link-then-enforce flow applyStage uses (link is idempotent + may keep an
// existing stage, so a follow-up PATCH pins the picked stage). The relational sync ran
// inside the create above, so source_row_id already resolves for the link. Non-fatal:
// a link failure must not lose the just-created investor.
if (newRowId && createForm.stage) {
try {
const linkResp = await api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify({
source_row_id: newRowId, contact_index: 0, name: `${name} — Pipeline`,
stage: createForm.stage, expected_amount: 0, probability: createForm.priority ? 55 : 35, fund_name: '',
}) }, token);
const opp = linkResp && linkResp.data;
if (opp && opp.id && opp.stage !== createForm.stage) {
await api(`/api/opportunities/${opp.id}/stage`, { method: 'PATCH', body: JSON.stringify({ stage: createForm.stage }) }, token);
}
} catch (_) { onShowToast('Investor added — but adding it to the pipeline failed', 'error'); }
}
// Optional: set a follow-up reminder linked to the new investor row (8g, Grant).
if (newRowId && createForm.reminderTitle.trim()) {
try {
await api('/api/reminders', { method: 'POST', body: JSON.stringify({
source_row_id: newRowId, investor_name: name,
title: createForm.reminderTitle.trim(), due_date: createForm.reminderDue || '', details: '',
}) }, token);
} catch (_) { onShowToast('Investor added — but setting the reminder failed', 'error'); }
}
// The create itself succeeded; if its response carried no row id we couldn't run the
// optional stage/reminder steps — say so rather than a clean success the user would trust.
const incomplete = !newRowId && (!!createForm.stage || !!createForm.reminderTitle.trim());
onShowToast(incomplete ? 'Investor added — reopen it to set the stage / reminder' : 'Investor added',
incomplete ? 'error' : 'success');
setCreateForm({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' });
closeSheet();
await reload(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to add investor'), 'error'); }
@@ -10326,7 +10381,7 @@
<span className="vp-name">{activeViewName}</span>
<span className="vp-caret"></span>
</button>
<button className="grid-new-btn" onClick={() => { setCreateForm({ name: '', contactName: '', contactEmail: '', note: '' }); setSheet('create'); }}>+ New</button>
<button className="grid-new-btn" onClick={() => { setCreateForm({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' }); setSheet('create'); }}>+ New</button>
</div>
<input className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="mobile-sortbar">
@@ -10380,6 +10435,35 @@
<label className="sheet-field-label">First note (optional)</label>
<textarea className="sheet-textarea" value={createForm.note} onChange={(e) => setCreateForm((f) => ({ ...f, note: e.target.value }))} placeholder="How you met, context…" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Initial pipeline stage (optional)</label>
<div className="stage-pick">
<button type="button" className={`stage-pick-btn ${createForm.stage === '' ? 'active' : ''}`} onClick={() => setCreateForm((f) => ({ ...f, stage: '' }))}>Not in pipeline</button>
{PIPELINE_STAGES.map((st) => (
<button type="button" key={st} className={`stage-pick-btn ${createForm.stage === st ? 'active' : ''}`} onClick={() => setCreateForm((f) => ({ ...f, stage: st }))}>
<StageChip stage={st} />
</button>
))}
</div>
</div>
<div className="sheet-field">
<label className="sheet-field-label">Disposition</label>
<button type="button" className={`sheet-toggle-opt ${createForm.priority ? 'on' : ''}`} aria-pressed={createForm.priority ? 'true' : 'false'}
onClick={() => setCreateForm((f) => ({ ...f, priority: !f.priority }))}>
<span>Flag as Priority</span>
<span className="check">{createForm.priority ? '✓' : ''}</span>
</button>
</div>
<div className="sheet-field">
<label className="sheet-field-label">Reminder (optional)</label>
<input className="sheet-input" value={createForm.reminderTitle} onChange={(e) => setCreateForm((f) => ({ ...f, reminderTitle: e.target.value }))} placeholder="e.g. Send Fund III deck" />
</div>
{createForm.reminderTitle.trim() && (
<div className="sheet-field">
<label className="sheet-field-label">Reminder due date</label>
<input className="sheet-input" type="date" value={createForm.reminderDue} onChange={(e) => setCreateForm((f) => ({ ...f, reminderDue: e.target.value }))} />
</div>
)}
<button className="sheet-submit" onClick={submitCreate} disabled={busy}>{busy ? 'Adding…' : 'Add investor'}</button>
</BottomSheet>