Mobile Phase 3a: read + write-supported Fundraising Grid surface

Adds the mobile-first Fundraising Grid (<768px): a lean MobileFundraisingGrid
that reads /api/fundraising/state once and renders an investor card list over
the active view (name, committed $, pipeline-stage chip, staleness-colored
recency, Existing-Investor accent, Priority corner; graveyard muted) with a
bottom-sheet view picker and search. Tap a card -> full-screen detail with
read-only commitments/contacts/notes plus edit sheets: log a note, pipeline
stage, set a reminder, and a "+ New" investor create flow with client-side
dedup typeahead.

All writes go through the targeted one-row endpoints (log-communication,
pipeline link, opportunities stage PATCH, reminders) — NEVER the whole-grid
PUT, which would race the multi-user grid (BRIEF §3a). FundraisingGridPage is
now a useIsMobile() wrapper over the renamed-but-untouched desktop grid and
the new mobile one (rules-of-hooks-safe; desktop unchanged).

Backend: inject a read-only opportunity_id into grid rows
(opportunity_id_by_source_row; added to both strip points) so the mobile detail
can PATCH a linked opp's stage directly. Earliest-opp-wins ordering keeps it
consistent with pipeline_stage and the link's canonical pick.

Editing an existing investor's name + contact pills stays read-only here
(deferred to P3b — needs a narrow per-row PATCH + pill editor).

Tests: test_grid_pipeline_link extended (opportunity_id inject/strip/round-trip);
36/36 backend green, render-smoke green.
This commit is contained in:
Keysat
2026-06-19 14:49:49 -05:00
parent 984b950f80
commit e34a6fc672
5 changed files with 635 additions and 23 deletions
+556 -2
View File
@@ -2136,6 +2136,95 @@
.fs-row-value.mono { font-family: 'IBM Plex Mono', monospace; }
.fs-copy-hint { color: var(--accent); margin-left: 6px; font-size: 12px; }
/* ─── Phase 3 — Fundraising Grid mobile surface (card list → detail → edit sheets) ──
JS-gated to MobileFundraisingGrid; reuses the Phase-2 .fs-detail / .sheet / .mobile-*
patterns. Card model is the locked spec (ROADMAP "Pipeline stages + investor flags"). */
.grid-toolbar-row { display: flex; gap: 8px; }
.view-picker-btn {
flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 8px;
height: var(--mobile-input-h); padding: 0 12px;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-control-radius);
color: var(--text-primary); font-size: var(--mobile-font-body); font-weight: 600;
font-family: inherit; cursor: pointer;
}
.view-picker-btn .vp-label { color: var(--text-subtle); font-size: 12px; flex: none; }
.view-picker-btn .vp-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.view-picker-btn .vp-caret { color: var(--text-muted); flex: none; margin-left: auto; }
.grid-new-btn {
flex: none; height: var(--mobile-input-h); padding: 0 16px;
border: none; border-radius: var(--mobile-control-radius);
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff; font-size: var(--mobile-font-body); font-weight: 600; font-family: inherit; cursor: pointer;
}
.grid-card {
position: relative; display: block; width: 100%; text-align: left; color: inherit;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-card-radius);
padding: 12px 14px; margin-bottom: var(--mobile-card-gap); cursor: pointer;
box-shadow: 0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
}
.grid-card:active { border-color: var(--border-strong); }
.grid-card.existing { border-left: 3px solid var(--accent); } /* Existing-Investor = left accent edge */
.grid-card.muted { opacity: 0.55; } /* graveyard rows */
.grid-card-priority { position: absolute; top: 11px; right: 13px; color: #fcd34d; font-size: 14px; line-height: 1; }
.grid-card-name {
font-size: var(--mobile-font-card-title); font-weight: 600; color: var(--text-primary);
padding-right: 22px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.grid-card-meta { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.grid-card-amount { font-family: 'IBM Plex Mono', monospace; font-size: var(--mobile-font-body); font-weight: 600; color: #6ee7b7; flex: none; }
.grid-card-amount.zero { color: var(--text-subtle); }
.grid-card-recency { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); margin-left: auto; flex: none; white-space: nowrap; }
.grid-card-recency.recency-aging { color: #e0b341; }
.grid-card-recency.recency-stale { color: #f87171; }
.stage-chip {
display: inline-block; padding: 2px 8px; border-radius: 4px; flex: none;
font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px;
}
/* Full-screen detail: read-only sections + edit-entry buttons. */
.fs-detail-star { color: var(--accent); font-size: 18px; margin-right: 8px; }
.fs-action-row { display: flex; gap: 8px; flex-wrap: wrap; }
.fs-action-btn {
flex: 1; min-width: 140px; min-height: var(--mobile-touch-target);
background: var(--bg-panel-elevated); border: 1px solid var(--border-strong);
border-radius: var(--mobile-control-radius); color: var(--text-primary);
font-size: 14px; font-family: inherit; cursor: pointer; padding: 0 12px;
}
.fs-action-btn:active { background: var(--bg-hover); }
.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; }
.sheet-field-label { display: block; font-size: 13px; color: var(--text-muted); margin-bottom: 6px; }
.sheet-input, .sheet-textarea, .sheet-select {
width: 100%; background: var(--bg-input); color: var(--text-primary);
border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
font-size: var(--mobile-font-body); font-family: inherit; padding: 0 12px; height: var(--mobile-input-h);
}
.sheet-textarea { height: auto; min-height: 96px; padding: 10px 12px; resize: vertical; line-height: 1.5; }
.sheet-input:focus, .sheet-textarea:focus, .sheet-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.sheet-submit {
width: 100%; min-height: var(--mobile-touch-target); border: none; border-radius: var(--mobile-control-radius);
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff; font-size: var(--mobile-font-body); font-weight: 600; font-family: inherit; cursor: pointer; margin-top: 4px;
}
.sheet-submit:disabled { opacity: 0.6; cursor: default; }
.sheet-remove {
width: 100%; min-height: var(--mobile-touch-target); margin-top: 10px;
background: transparent; border: 1px solid #7a3030; color: #e06c6c;
border-radius: var(--mobile-control-radius); font-size: 14px; font-family: inherit; cursor: pointer;
}
.dedup-box { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 8px 12px; margin: -4px 0 14px; }
.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; }
/* Visibility utilities — base = desktop; flipped under the breakpoint. */
.mobile-only { display: none; }
@@ -3564,6 +3653,83 @@
advisor: 'badge-advisor', other: 'badge-other'
}[type] || 'badge-other');
/* ─── Shared grid helpers (Phase 3 — mobile Fundraising Grid) ─────────────────────
Pure functions so the mobile card list filters rows the SAME way the desktop grid's
displayedRows does (ported from FundraisingGridPage), without sharing its closure. */
// Mobile money format (sourced from the comps): >=1e6 -> $N[.N]M (drop .0); >=1e3 -> $NK; else $N.
const formatMoneyMobile = (n) => {
const v = Number(n) || 0;
if (v >= 1e6) { const m = v / 1e6; return '$' + (m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)) + 'M'; }
if (v >= 1e3) return '$' + Math.round(v / 1e3) + 'K';
return '$' + Math.round(v);
};
const daysSince = (iso) => {
if (!iso) return null;
const d = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
return Number.isFinite(d) ? d : null;
};
const formatAgeShort = (days) => {
if (days == null) return '';
if (days <= 0) return 'today';
if (days < 7) return days + 'd';
if (days < 30) return Math.floor(days / 7) + 'w';
if (days < 365) return Math.floor(days / 30) + 'mo';
return Math.floor(days / 365) + 'y';
};
const gridRollup = (row, fundColumnIds) => fundColumnIds.reduce((s, id) => s + parseNumericInput(row[id]), 0);
const gridFilterableValue = (row, col, fundColumnIds) => {
if (!col) return '';
if (col.id === 'total_invested') return gridRollup(row, fundColumnIds);
if (col.type === 'contacts') {
return (Array.isArray(row[col.id]) ? row[col.id] : [])
.map((c) => `${c.name || ''} ${c.email || ''}`).join(' ');
}
return row[col.id];
};
const gridRuleMatches = (row, rule, columns, fundColumnIds) => {
const col = columns.find((c) => c.id === rule.colId);
if (!col) return true;
const cv = gridFilterableValue(row, col, fundColumnIds);
const op = rule.op || 'contains';
const rv = rule.value ?? '';
if (op === 'contains') return String(cv || '').toLowerCase().includes(String(rv).toLowerCase());
if (op === 'equals') return String(cv || '').toLowerCase() === String(rv).toLowerCase();
if (op === 'not_equals') return String(cv || '').toLowerCase() !== String(rv).toLowerCase();
if (op === 'is_true') return !!cv;
if (op === 'is_false') return !cv;
if (op === 'gt') return parseNumericInput(cv) > parseNumericInput(rv);
if (op === 'lt') return parseNumericInput(cv) < parseNumericInput(rv);
if (op === 'on_or_after') return String(cv || '') >= String(rv || '');
if (op === 'on_or_before') return String(cv || '') <= String(rv || '');
return true;
};
// Mirror of the desktop displayedRows base filter (graveyard / follow-up / lead flags +
// saved columnFilters). Search + sort are applied by the caller. Caveat: a columnFilter on a
// FORMULA column isn't evaluated here (gridFilterableValue returns the raw cell, not the
// computed value) — the mobile default views don't use those; revisit if a formula-filter view ships.
const gridRowMatchesView = (row, view, columns, fundColumnIds) => {
const f = (view && view.filters) || {};
if (f.graveyardOnly && !row.graveyard) return false;
if (!f.graveyardOnly && !f.includeGraveyard && row.graveyard) return false;
if (f.followUpOnly && !row.follow_up) return false;
if (f.lead && row.lead !== f.lead) return false;
const cf = Array.isArray(view && view.columnFilters) ? view.columnFilters : [];
return cf.every((rule) => gridRuleMatches(row, rule, columns, fundColumnIds));
};
// Pipeline-stage chip — reuses PIPELINE_STAGE_CHIP tints (DESIGN §2), mono-uppercase on mobile.
const StageChip = ({ stage }) => {
const s = String(stage || '');
if (!s) return null;
const sc = PIPELINE_STAGE_CHIP[s] || { color: '#8ea2b7', border: '#3a4a5e' };
return (
<span className="stage-chip" style={{ color: sc.color, border: `1px solid ${sc.border}`, backgroundColor: sc.color + '1a' }}>
{pipelineStageLabel(s)}
</span>
);
};
const LoginPage = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -5704,7 +5870,10 @@
);
};
const FundraisingGridPage = ({ user, token, onShowToast, views, activeView, setActiveView, setViews, uiAction, onUiActionHandled }) => {
// Desktop Fundraising Grid (the spreadsheet + autosave). Unchanged; rendered on >768px via
// the FundraisingGridPage switch at the end of this component. Mobile (<768px) renders the
// lean MobileFundraisingGrid instead — which never whole-grid PUTs (BRIEF §3a).
const DesktopFundraisingGridPage = ({ user, token, onShowToast, views, activeView, setActiveView, setViews, uiAction, onUiActionHandled }) => {
const STORAGE_KEY = FUNDRAISING_GRID_STORAGE_KEY;
const teamMembers = ['Grant', 'JK', 'GG', 'MB', 'Unassigned'];
@@ -5946,7 +6115,7 @@
// signals (like pipeline_stage) — strip them so they never dirty the autosave or
// get persisted into the blob. Their desktop column + mobile-card rendering lands
// with the mobile surfaces (Phase 3); injecting them now keeps that pure-frontend.
const { pipeline, pipeline_stage, reminder_status,
const { pipeline, pipeline_stage, opportunity_id, reminder_status,
existing_investor, last_activity_at, staleness, ...rest } = r;
return rest;
}) : rs);
@@ -8490,6 +8659,391 @@
);
};
// Mobile Fundraising Grid (<768px) P3a 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).
const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView }) => {
const [columns, setColumns] = useState([]);
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null);
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: '' });
const reload = useCallback(async (silent) => {
try {
if (!silent) setLoading(true);
const result = await api('/api/fundraising/state', {}, token);
const grid = (result && result.data && result.data.grid) || {};
setColumns(Array.isArray(grid.columns) ? grid.columns : []);
setRows(Array.isArray(grid.rows) ? grid.rows : []);
setError('');
} catch (err) {
setError(getErrorMessage(err, 'Failed to load the grid'));
} finally {
if (!silent) setLoading(false);
}
}, [token]);
useEffect(() => { reload(); }, [reload]);
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]);
const activeViewName = (activeViewObj && activeViewObj.name) || 'All Investors';
const displayed = useMemo(() => {
const base = rows.filter((r) => r && typeof r === 'object'
&& gridRowMatchesView(r, activeViewObj, columns, fundColumnIds));
const q = search.trim().toLowerCase();
const searched = !q ? base : base.filter((r) => {
// Same searched text as the desktop grid's rowText (name + notes + contact name/email/geo).
const contactText = (r.contacts || []).map((c) => `${c.name || ''} ${c.email || ''} ${c.city || ''} ${c.state || ''} ${c.country || ''}`).join(' ');
return `${r.investor_name || ''} ${r.notes || ''} ${contactText}`.toLowerCase().includes(q);
});
return [...searched].sort((a, b) => String(a.investor_name || '')
.localeCompare(String(b.investor_name || ''), undefined, { sensitivity: 'base' }));
}, [rows, activeViewObj, columns, fundColumnIds, search]);
const selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) || null, [rows, selectedId]);
const closeSheet = () => setSheet(null);
// ── writes (targeted one-row endpoints only) ──
const submitNote = async () => {
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,
}) }, token);
onShowToast('Note logged', 'success');
setNoteForm({ type: 'note', subject: '', body: '' });
closeSheet();
await reload(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to log note'), 'error'); }
finally { setBusy(false); }
};
const applyStage = async (stage) => {
const row = selectedRow; if (!row) return;
setBusy(true);
try {
if (row.pipeline && row.opportunity_id) {
// Already in the pipeline → patch the linked opp directly (same endpoint the
// Pipeline board uses), via the read-only opportunity_id injected on the row.
await api(`/api/opportunities/${row.opportunity_id}/stage`, { method: 'PATCH', body: JSON.stringify({ stage }) }, token);
} else {
if (!(Array.isArray(row.contacts) && row.contacts.length)) {
onShowToast('Add a contact before adding to the pipeline', 'error'); setBusy(false); return;
}
// link creates the opp at `stage`; but if the row was already linked (e.g. another
// user linked it after our last load) link is idempotent and KEEPS the existing
// stage — so enforce the picked stage with a follow-up PATCH on the returned opp.
const resp = await api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify({
source_row_id: row.id, contact_index: 0, name: `${row.investor_name || 'Investor'} — Pipeline`,
stage, expected_amount: 0, probability: row.priority ? 55 : 35, fund_name: '',
}) }, token);
const opp = resp && resp.data;
if (opp && opp.id && opp.stage !== stage) {
await api(`/api/opportunities/${opp.id}/stage`, { method: 'PATCH', body: JSON.stringify({ stage }) }, token);
}
}
onShowToast('Stage updated', 'success');
closeSheet();
await reload(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to update stage'), 'error'); }
finally { setBusy(false); }
};
const removePipeline = async () => {
const row = selectedRow; if (!row) return;
if (!window.confirm(`Remove ${row.investor_name || 'this investor'} from the pipeline? The deal is archived (recoverable); the grid row is untouched.`)) return;
setBusy(true);
try {
await api('/api/fundraising/pipeline/unlink', { method: 'POST', body: JSON.stringify({ source_row_id: row.id }) }, token);
onShowToast('Removed from the pipeline', 'success');
closeSheet();
await reload(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to remove from pipeline'), 'error'); }
finally { setBusy(false); }
};
const submitReminder = async () => {
const row = selectedRow; if (!row) return;
if (!String(reminderForm.title || '').trim()) { onShowToast('A reminder needs a title', 'error'); return; }
setBusy(true);
try {
await api('/api/reminders', { method: 'POST', body: JSON.stringify({
source_row_id: row.id, investor_name: row.investor_name || '',
title: reminderForm.title.trim(), due_date: reminderForm.due_date || '', details: reminderForm.details || '',
}) }, token);
onShowToast('Reminder set', 'success');
setReminderForm({ title: '', due_date: '', details: '' });
closeSheet();
await reload(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to set reminder'), 'error'); }
finally { setBusy(false); }
};
const createDupes = useMemo(() => {
const q = createForm.name.trim().toLowerCase();
if (q.length < 2) return [];
return rows.filter((r) => String(r.investor_name || '').toLowerCase().includes(q)).slice(0, 4);
}, [createForm.name, rows]);
const submitCreate = async () => {
const name = createForm.name.trim();
const cName = createForm.contactName.trim();
if (!name) { onShowToast('Investor name is required', 'error'); return; }
if (!cName) { onShowToast('Add at least one contact name', 'error'); return; }
setBusy(true);
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).
const hasNote = !!String(createForm.note || '').trim();
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,
}) }, token);
onShowToast('Investor added', 'success');
setCreateForm({ name: '', contactName: '', contactEmail: '', note: '' });
closeSheet();
await reload(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to add investor'), 'error'); }
finally { setBusy(false); }
};
const renderCard = (row) => {
const committed = gridRollup(row, fundColumnIds);
const days = daysSince(row.last_activity_at);
const recencyCls = row.staleness === 'stale' ? 'recency-stale' : row.staleness === 'aging' ? 'recency-aging' : '';
const cls = `grid-card${row.existing_investor ? ' existing' : ''}${row.graveyard ? ' muted' : ''}`;
return (
<button className={cls} key={row.id} onClick={() => setSelectedId(row.id)}>
{row.priority && <span className="grid-card-priority" title="Priority"></span>}
<div className="grid-card-name">{row.investor_name || 'Unnamed investor'}</div>
<div className="grid-card-meta">
<span className={`grid-card-amount${committed > 0 ? '' : ' zero'}`}>{formatMoneyMobile(committed)}</span>
{row.pipeline && <StageChip stage={row.pipeline_stage} />}
<span className={`grid-card-recency ${recencyCls}`}>
{days == null ? 'no activity' : formatAgeShort(days) + (days <= 0 ? '' : ' ago')}{row.staleness === 'stale' ? ' · stale' : ''}
</span>
</div>
</button>
);
};
return (
<div className="mobile-screen">
<div className="mobile-toolbar">
<div className="grid-toolbar-row">
<button className="view-picker-btn" onClick={() => setSheet('view')}>
<span className="vp-label">VIEW</span>
<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>
</div>
<input className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="mobile-sortbar">
<span className="mobile-count">{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'}</span>
</div>
</div>
{loading ? (
<SkeletonBlock lines={8} />
) : error ? (
<div className="empty-state">{error}</div>
) : displayed.length === 0 ? (
<div className="empty-state">No investors in this view</div>
) : (
displayed.map(renderCard)
)}
<BottomSheet open={sheet === 'view'} onClose={closeSheet} title="Views">
{views.map((v) => (
<button key={v.id} className={`sheet-option ${v.id === activeView ? 'active' : ''}`} onClick={() => { setActiveView(v.id); closeSheet(); }}>
<span>{v.name}</span>
{v.id === activeView && <span className="sheet-option-check"></span>}
</button>
))}
</BottomSheet>
<BottomSheet open={sheet === 'create'} onClose={closeSheet} title="New investor">
<div className="sheet-field">
<label className="sheet-field-label">Investor name</label>
<input className="sheet-input" value={createForm.name} onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. Acme Capital" />
</div>
{createDupes.length > 0 && (
<div className="dedup-box">
<div className="dedup-box-title">Possible existing matches — tap a card instead of creating a duplicate</div>
{createDupes.map((r) => (<div className="dedup-match" key={r.id}>{r.investor_name}</div>))}
</div>
)}
<div className="sheet-field">
<label className="sheet-field-label">Primary contact name</label>
<input className="sheet-input" value={createForm.contactName} onChange={(e) => setCreateForm((f) => ({ ...f, contactName: e.target.value }))} placeholder="Full name" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Contact email (optional)</label>
<input className="sheet-input" type="email" value={createForm.contactEmail} onChange={(e) => setCreateForm((f) => ({ ...f, contactEmail: e.target.value }))} placeholder="name@firm.com" />
</div>
<div className="sheet-field">
<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>
<button className="sheet-submit" onClick={submitCreate} disabled={busy}>{busy ? 'Adding…' : 'Add investor'}</button>
</BottomSheet>
{selectedRow && (() => {
const row = selectedRow;
const committed = gridRollup(row, fundColumnIds);
const days = daysSince(row.last_activity_at);
const fundsHeld = fundColumns.filter((c) => parseNumericInput(row[c.id]) > 0);
const contacts = Array.isArray(row.contacts) ? row.contacts : [];
const recencyColor = row.staleness === 'stale' ? '#f87171' : row.staleness === 'aging' ? '#e0b341' : undefined;
return (
<div className="fs-detail" role="dialog" aria-modal="true">
<div className="fs-detail-header">
<button className="fs-detail-back" onClick={() => setSelectedId(null)}> Grid</button>
</div>
<div className="fs-detail-body">
<div className="fs-detail-id">
<span style={{ minWidth: 0, flex: 1 }}>
<div className="fs-detail-title">
{row.existing_investor && <span className="fs-detail-star" title="Existing investor"></span>}
{row.investor_name || 'Unnamed investor'}
</div>
<div className="fs-detail-subtitle">{formatMoneyMobile(committed)} committed{row.lead ? ` · ${row.lead}` : ''}</div>
</span>
{row.priority && <span className="badge" style={{ background: '#fcd34d22', color: '#fcd34d' }}>Priority</span>}
</div>
<div className="fs-section">
<div className="fs-section-label">Pipeline</div>
<div className="fs-row">
<span className="fs-row-label">Stage</span>
<span className="fs-row-value">
{row.pipeline ? <StageChip stage={row.pipeline_stage} /> : <span style={{ color: 'var(--text-subtle)' }}>Not in pipeline</span>}
</span>
</div>
<div className="fs-action-row" style={{ marginTop: '10px' }}>
<button className="fs-action-btn" onClick={() => setSheet('stage')}>{row.pipeline ? 'Change stage' : 'Add to pipeline'}</button>
</div>
</div>
<div className="fs-section">
<div className="fs-section-label">Commitments</div>
<div className="fs-row"><span className="fs-row-label">Total committed</span><span className="fs-row-value mono">{formatMoneyMobile(committed)}</span></div>
{fundsHeld.map((c) => (
<div className="fs-row" key={c.id}><span className="fs-row-label">{c.label}</span><span className="fs-row-value mono">{formatMoneyMobile(parseNumericInput(row[c.id]))}</span></div>
))}
<div style={{ fontSize: '12px', color: 'var(--text-subtle)', marginTop: '8px' }}>Amounts are read-only on mobile — edit on desktop.</div>
</div>
<div className="fs-section">
<div className="fs-section-label">Contacts</div>
{contacts.length ? contacts.map((c, i) => (
<div className="fs-pill" key={i}>
<div className="fs-pill-name">{c.name || '—'}</div>
{c.email && <div className="fs-pill-email">{c.email}</div>}
</div>
)) : <div style={{ color: 'var(--text-subtle)', fontSize: '13px' }}>No contacts yet.</div>}
</div>
<div className="fs-section">
<div className="fs-section-label">Activity</div>
<div className="fs-row">
<span className="fs-row-label">Last contact</span>
<span className="fs-row-value mono" style={{ color: recencyColor }}>
{days == null ? 'No activity' : (formatAgeShort(days) + (days <= 0 ? '' : ' ago'))}{row.staleness === 'stale' ? ' · stale' : ''}
</span>
</div>
{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>
</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>
<BottomSheet open={sheet === 'stage'} onClose={closeSheet} title="Pipeline stage">
{PIPELINE_STAGES.map((st) => (
<button key={st} className={`sheet-option ${row.pipeline_stage === st ? 'active' : ''}`} onClick={() => applyStage(st)} disabled={busy}>
<span>{pipelineStageLabel(st)}</span>
{row.pipeline_stage === st && <span className="sheet-option-check"></span>}
</button>
))}
{row.pipeline && <button className="sheet-remove" onClick={removePipeline} disabled={busy}>Remove from pipeline</button>}
</BottomSheet>
<BottomSheet open={sheet === 'reminder'} onClose={closeSheet} title="Set a reminder">
<div className="sheet-field">
<label className="sheet-field-label">Title</label>
<input className="sheet-input" value={reminderForm.title} onChange={(e) => setReminderForm((f) => ({ ...f, title: e.target.value }))} placeholder="e.g. Follow up on Fund III" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Due date</label>
<input className="sheet-input" type="date" value={reminderForm.due_date} onChange={(e) => setReminderForm((f) => ({ ...f, due_date: e.target.value }))} />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Details (optional)</label>
<textarea className="sheet-textarea" value={reminderForm.details} onChange={(e) => setReminderForm((f) => ({ ...f, details: e.target.value }))} />
</div>
<button className="sheet-submit" onClick={submitReminder} disabled={busy}>{busy ? 'Saving…' : 'Set reminder'}</button>
</BottomSheet>
</div>
);
})()}
</div>
);
};
// Switch by viewport (rules-of-hooks-safe — only useIsMobile() runs here; the surfaces
// mount/unmount on a breakpoint cross, each owning its own hooks).
const FundraisingGridPage = (props) => {
const isMobile = useIsMobile();
return isMobile ? <MobileFundraisingGrid {...props} /> : <DesktopFundraisingGridPage {...props} />;
};
const InstructionsPage = () => {
return (
<div className="page-container">