Mobile P3b: investor name + contact-pill editing (update-row)

Adds the editable half of BRIEF §3a's mobile grid set: rename an investor
and add/edit/remove its contact pills from the mobile detail sheet.

New POST /api/fundraising/update-row is the one-row read-fresh-modify-write
twin of log-communication: it mutates only the target row's name/contacts in
the canonical grid blob server-side, then bumps the version + re-syncs the
relational tables. It never accepts a whole-grid payload, so a stale mobile
client can't clobber concurrent edits to other rows (the reason mobile avoids
the whole-grid PUT). _sanitize_fundraising_contacts whitelists the known pill
fields as the trust boundary; removing a pill is soft on the classic contacts
directory (only the grid pill + fundraising_contacts row drop).

Frontend: MobileFundraisingGrid gains an Edit bottom-sheet (name input + pill
editor with client-side dedup); money stays desktop-only. New CSS is
theme-var-only so it flips in light mode.

Verified: test_fundraising_update_row.py (24 assertions, real HTTP), full
suite 37/37, render-smoke + a 375px jsdom interaction harness green.
This commit is contained in:
Keysat
2026-06-19 17:07:29 -05:00
parent 099d87dad2
commit 3f93daf28e
4 changed files with 436 additions and 27 deletions
+84 -5
View File
@@ -2320,6 +2320,16 @@
.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; }
/* 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; }
.pill-edit-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.pill-edit-label { font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-muted); text-transform: uppercase; }
.pill-edit-remove { background: transparent; border: none; color: var(--danger-soft); font-size: 22px; line-height: 1; cursor: pointer; padding: 0 4px; }
.pill-edit .sheet-input { margin-bottom: 8px; }
.pill-edit .sheet-input:last-child { margin-bottom: 0; }
.sheet-addbtn { width: 100%; min-height: var(--mobile-touch-target); margin-bottom: 14px; background: transparent; border: 1px dashed var(--border); color: var(--accent-light); border-radius: var(--mobile-control-radius); font-size: 14px; font-family: inherit; cursor: pointer; }
/* ─── Phase 4 — Pipeline mobile surface (swipe-between-stages) ─────────────────────
JS-gated to MobilePipeline; reuses the .fs-detail / .sheet / .stage-chip patterns.
Stage segmented control (count-forward) → horizontal scroll-snap stage pages → dots;
@@ -9358,12 +9368,12 @@
);
};
// Mobile Fundraising Grid (<768px) P3a of the mobile-first redesign. A lean card list
// Mobile Fundraising Grid (<768px) P3a/P3b of the mobile-first redesign. A lean card list
// full-screen detail → edit sheets. Reads /api/fundraising/state once; ALL writes go through
// the targeted one-row endpoints (log-communication / pipeline link+stage / reminders), NEVER
// the whole-grid PUT (BRIEF §3a — that would race the 5-person live grid). Editable here:
// create investor, log a note, pipeline stage, set a reminder. Renaming + contact-pill edits
// on an existing row are read-only in P3a (need a narrow per-row PATCH — deferred to P3b).
// the targeted one-row endpoints (log-communication / update-row / pipeline link+stage /
// 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 [columns, setColumns] = useState([]);
const [rows, setRows] = useState([]);
@@ -9376,6 +9386,9 @@
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
const [noteForm, setNoteForm] = useState({ type: 'note', subject: '', body: '' });
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
// 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: [] });
const reload = useCallback(async (silent) => {
try {
@@ -9526,6 +9539,50 @@
finally { setBusy(false); }
};
// ── edit (name + contact pills) — version-safe one-row POST, never the whole-grid PUT ──
const openEdit = () => {
const row = selectedRow; if (!row) return;
setEditForm({
name: row.investor_name || '',
contacts: (Array.isArray(row.contacts) ? row.contacts : []).map((c) => ({ ...c })),
});
setSheet('edit');
};
const setPill = (i, patch) => setEditForm((f) => ({
...f, contacts: f.contacts.map((c, j) => (j === i ? { ...c, ...patch } : c)),
}));
const addPill = () => setEditForm((f) => ({ ...f, contacts: [...f.contacts, { name: '', email: '', title: '' }] }));
const removePill = (i) => setEditForm((f) => ({ ...f, contacts: f.contacts.filter((_, j) => j !== i) }));
const submitEdit = async () => {
const row = selectedRow; if (!row) return;
const name = String(editForm.name || '').trim();
if (!name) { onShowToast('Investor name is required', 'error'); return; }
// Client-side dedup (BRIEF §3a): drop blank pills, then collapse duplicates by email
// (preferred key) else by name — keeping each pill's preserved fields on the survivor.
const seen = new Set();
const contacts = [];
for (const c of editForm.contacts) {
const cn = String(c.name || '').trim();
const ce = String(c.email || '').trim();
if (!cn && !ce) continue;
const key = ce ? `e:${ce.toLowerCase()}` : `n:${cn.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
contacts.push({ ...c, name: cn, email: ce, title: String(c.title || '').trim() });
}
setBusy(true);
try {
await api('/api/fundraising/update-row', { method: 'POST', body: JSON.stringify({
row_id: row.id, investor_name: name, contacts,
}) }, token);
onShowToast('Investor updated', 'success');
closeSheet();
await reload(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to update investor'), 'error'); }
finally { setBusy(false); }
};
const renderCard = (row) => {
const committed = gridRollup(row, fundColumnIds);
const days = daysSince(row.last_activity_at);
@@ -9619,6 +9676,7 @@
<div className="fs-detail" role="dialog" aria-modal="true">
<div className="fs-detail-header">
<button className="fs-detail-back" onClick={() => setSelectedId(null)}> Grid</button>
<button className="fs-detail-edit" onClick={openEdit}>Edit</button>
</div>
<div className="fs-detail-body">
<div className="fs-detail-id">
@@ -9729,6 +9787,27 @@
</div>
<button className="sheet-submit" onClick={submitReminder} disabled={busy}>{busy ? 'Saving…' : 'Set reminder'}</button>
</BottomSheet>
<BottomSheet open={sheet === 'edit'} onClose={closeSheet} title="Edit investor">
<div className="sheet-field">
<label className="sheet-field-label">Investor name</label>
<input className="sheet-input" value={editForm.name} onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. Acme Capital" />
</div>
<label className="sheet-field-label">Contacts</label>
{editForm.contacts.map((c, i) => (
<div className="pill-edit" key={i}>
<div className="pill-edit-head">
<span className="pill-edit-label">Contact {i + 1}</span>
<button className="pill-edit-remove" type="button" aria-label="Remove contact" onClick={() => removePill(i)}>×</button>
</div>
<input className="sheet-input" value={c.name || ''} onChange={(e) => setPill(i, { name: e.target.value })} placeholder="Full name" />
<input className="sheet-input" type="email" value={c.email || ''} onChange={(e) => setPill(i, { email: e.target.value })} placeholder="name@firm.com" />
<input className="sheet-input" value={c.title || ''} onChange={(e) => setPill(i, { title: e.target.value })} placeholder="Title (optional)" />
</div>
))}
<button className="sheet-addbtn" type="button" onClick={addPill}>+ Add contact</button>
<button className="sheet-submit" onClick={submitEdit} disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button>
</BottomSheet>
</div>
);
})()}