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