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:
+84
-5
@@ -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>
|
||||
);
|
||||
})()}
|
||||
|
||||
Reference in New Issue
Block a user