Adopt the Pipeline: grid-driven opportunities link (v0.1.0:87)

The fundraising grid (canonical) now drives the classic opportunities
Pipeline board, instead of the board being a disconnected second data-entry
surface. An "Add to Pipeline" row action creates a durably-linked opportunity
via the new opportunities.fundraising_investor_id (migration 0005, additive +
reversible), reusing the grid's already-synced contact — retiring the
POST /api/contacts side-door — and mapping the grid lead to the opp owner.

Ownership is split so the two stay reconciled: the grid owns whether the link
exists and the seed; the board owns stage/probability/owner. The link endpoint
is idempotent (one live opp per investor; a re-link never reseeds funnel
fields). "Is in pipeline?"/"what stage?" are derived from a live opp join and
injected as read-only grid columns on read, stripped on write, so they never
persist or dirty the autosave. Remove-from-pipeline soft-deletes the opp and
leaves the grid row fully intact; deleting an investor from the grid archives
its orphaned opp.

Also fixes the standing soft-delete leak in handle_pipeline_report and the
dashboard pipeline aggregates, which counted tombstoned opportunities.

Tests: backend/test_grid_pipeline_link.py (link/idempotent/round-trip/guards/
unlink-intact/re-link/orphan/aggregates); 28/28 suite green, render-smoke green.
This commit is contained in:
Keysat
2026-06-17 23:08:36 -05:00
parent 06482247df
commit 7f9a15ebf3
10 changed files with 724 additions and 89 deletions
+134 -74
View File
@@ -4890,6 +4890,8 @@
{ id: 'follow_up', label: 'Follow up', type: 'checkbox', width: 110 },
{ id: 'lead', label: 'Lead', type: 'select', options: teamMembers, width: 130 },
{ id: 'graveyard', label: 'Graveyard', type: 'checkbox', width: 115 },
{ id: 'pipeline', label: 'Pipeline', type: 'action', readOnly: true, width: 120 },
{ id: 'pipeline_stage', label: 'Pipeline Stage', type: 'text', readOnly: true, width: 150 },
{ id: 'fund_i', label: 'Fund I', type: 'currency', isFund: true, width: 130 },
{ id: 'fund_ii', label: 'Fund II', type: 'currency', isFund: true, width: 130 },
{ id: 'fund_iii', label: 'Fund III', type: 'currency', isFund: true, width: 130 },
@@ -5039,6 +5041,25 @@
else cols.push(col);
changed = true;
}
// Pipeline-adoption columns: a per-row "Add to / In Pipeline" control and a
// read-only mirror of the linked opportunity's stage. Both are injected here so
// existing saved grids pick them up; their VALUES are server-computed on read.
const hasPipeline = cols.some((c) => c.id === 'pipeline');
if (!hasPipeline) {
const gy = cols.findIndex((c) => c.id === 'graveyard');
const col = { id: 'pipeline', label: 'Pipeline', type: 'action', readOnly: true, width: 120 };
if (gy >= 0) cols.splice(gy + 1, 0, col);
else cols.push(col);
changed = true;
}
const hasPipelineStage = cols.some((c) => c.id === 'pipeline_stage');
if (!hasPipelineStage) {
const pi = cols.findIndex((c) => c.id === 'pipeline');
const col = { id: 'pipeline_stage', label: 'Pipeline Stage', type: 'text', readOnly: true, width: 150 };
if (pi >= 0) cols.splice(pi + 1, 0, col);
else cols.push(col);
changed = true;
}
const rowsIn = Array.isArray(incomingRows) ? incomingRows : defaultRows;
const rowsOut = rowsIn.map((r) => {
const next = { ...r };
@@ -5059,6 +5080,16 @@
return { columns: cols, rows: rowsOut, changed };
};
// `pipeline` / `pipeline_stage` are read-only, server-computed (from the linked
// opportunity) and injected on GET. They must never participate in dirty-detection
// or be persisted — otherwise a link/unlink flips a value and triggers a no-op
// autosave + version bump. Strip them at every snapshot / persist boundary.
const stripComputedRows = (rs) => (Array.isArray(rs) ? rs.map((r) => {
if (!r || typeof r !== 'object') return r;
const { pipeline, pipeline_stage, ...rest } = r;
return rest;
}) : rs);
useEffect(() => {
let cancelled = false;
const hydrateFundraisingState = async () => {
@@ -5089,8 +5120,8 @@
setActiveView(incomingViews[0]?.id || 'view-main');
}
setRemoteVersion(Number.isFinite(incomingVersion) && incomingVersion > 0 ? incomingVersion : 1);
lastSyncedSnapshotRef.current = JSON.stringify({ columns: incomingColumns, rows: incomingRows, views: incomingViews });
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: incomingColumns, rows: incomingRows }));
lastSyncedSnapshotRef.current = JSON.stringify({ columns: incomingColumns, rows: stripComputedRows(incomingRows), views: incomingViews });
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: incomingColumns, rows: stripComputedRows(incomingRows) }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(incomingViews));
} catch (err) {
onShowToast(getErrorMessage(err, 'Using local fundraising data'), 'error');
@@ -5108,7 +5139,8 @@
useEffect(() => {
if (!stateHydrated || !Number.isFinite(remoteVersion)) return;
const snapshot = JSON.stringify({ columns, rows, views });
const persistRows = stripComputedRows(rows);
const snapshot = JSON.stringify({ columns, rows: persistRows, views });
if (snapshot === lastSyncedSnapshotRef.current) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
@@ -5117,7 +5149,7 @@
const response = await api('/api/fundraising/state', {
method: 'PUT',
body: JSON.stringify({
grid: { columns, rows },
grid: { columns, rows: persistRows },
views,
expected_version: remoteVersion
})
@@ -5136,7 +5168,7 @@
next_version: Number.isFinite(nextVersion) ? nextVersion : null
});
lastSyncedSnapshotRef.current = snapshot;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows }));
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows: persistRows }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views));
} catch (err) {
if (err?.status === 409) {
@@ -5167,7 +5199,7 @@
}
onShowToast(getErrorMessage(err, 'Failed to save fundraising state'), 'error');
pushImportDebugEvent('autosave-error', { message: getErrorMessage(err, 'save failed') });
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows }));
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows: persistRows }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views));
}
}, 550);
@@ -5724,7 +5756,11 @@
const openCreateOpportunityModal = (row) => {
if (!row) return;
const contacts = Array.isArray(row.contacts) ? row.contacts : [];
const defaultName = `${row.investor_name || 'Investor'} Opportunity`;
if (contacts.length === 0) {
onShowToast('Add at least one contact to this investor row before adding it to the pipeline', 'error');
return;
}
const defaultName = `${row.investor_name || 'Investor'} — Pipeline`;
setCreateOppContext({ rowId: row.id, investorName: row.investor_name || '', contacts });
setCreateOppForm({
name: defaultName,
@@ -5737,78 +5773,65 @@
setShowCreateOppModal(true);
};
// Grid is canonical: the server resolves the contact from the row's already-synced
// contact pill (no POST /api/contacts side-door) and links the opp durably, so the
// bot/board can never spawn a duplicate. We only pass the seed.
const submitCreateOpportunity = async () => {
if (!createOppContext?.investorName) return;
if (!createOppContext?.rowId) return;
setCreateOppSubmitting(true);
const rowId = createOppContext.rowId;
const payload = {
source_row_id: rowId,
contact_index: Number(createOppForm.contactIndex) || 0,
name: String(createOppForm.name || '').trim(),
stage: createOppForm.stage || 'lead',
expected_amount: parseNumericInput(createOppForm.expected_amount),
probability: Number(createOppForm.probability) || 35,
fund_name: String(createOppForm.fund_name || '').trim()
};
const doLink = () => api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify(payload) }, token);
try {
const contacts = Array.isArray(createOppContext.contacts) ? createOppContext.contacts : [];
const selected = contacts[createOppForm.contactIndex] || contacts[0] || null;
if (!selected) {
onShowToast('Add at least one contact on the investor row first', 'error');
return;
let resp;
try {
resp = await doLink();
} catch (err) {
// A row added moments ago may not have autosaved/synced yet — wait for the
// debounced save to flush, then retry once.
if (err?.status === 404) {
await new Promise((r) => setTimeout(r, 700));
resp = await doLink();
} else {
throw err;
}
}
const allContactsResp = await api('/api/contacts?limit=1000', {}, token);
const allContacts = Array.isArray(allContactsResp?.data) ? allContactsResp.data : [];
const selectedEmail = String(selected.email || '').trim().toLowerCase();
const selectedName = String(selected.name || '').trim().toLowerCase();
const investorName = String(createOppContext.investorName || '').trim().toLowerCase();
let matched = null;
if (selectedEmail) {
matched = allContacts.find((c) => String(c.email || '').trim().toLowerCase() === selectedEmail) || null;
}
if (!matched && selectedName) {
matched = allContacts.find((c) => {
const n = `${c.first_name || ''} ${c.last_name || ''}`.trim().toLowerCase();
const org = String(c.organization_name || c.organization || '').trim().toLowerCase();
return n === selectedName && org === investorName;
}) || null;
}
let contactId = matched?.id || null;
if (!contactId) {
const split = splitFullName(selected.name || '');
const created = await api('/api/contacts', {
method: 'POST',
body: JSON.stringify({
first_name: split.first,
last_name: split.last,
email: selected.email || '',
title: selected.title || '',
organization: createOppContext.investorName,
contact_type: 'investor',
status: 'active'
})
}, token);
contactId = created?.data?.id || null;
}
if (!contactId) throw new Error('Could not resolve contact');
await api('/api/opportunities', {
method: 'POST',
body: JSON.stringify({
name: String(createOppForm.name || '').trim() || `${createOppContext.investorName} Opportunity`,
contact_id: contactId,
stage: createOppForm.stage || 'lead',
expected_amount: parseNumericInput(createOppForm.expected_amount),
probability: Number(createOppForm.probability) || 35,
fund_name: String(createOppForm.fund_name || '').trim(),
priority: rows.find((r) => r.id === createOppContext.rowId)?.priority ? 'high' : 'medium'
})
}, token);
onShowToast('Pipeline opportunity created', 'success');
const stage = resp?.data?.stage || payload.stage;
const already = resp?.already_linked;
setRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, pipeline: true, pipeline_stage: stage } : r)));
onShowToast(already ? 'Already in the pipeline' : 'Added to the pipeline', already ? 'info' : 'success');
setShowCreateOppModal(false);
setCreateOppContext(null);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to create pipeline opportunity'), 'error');
onShowToast(getErrorMessage(err, 'Failed to add to the pipeline'), 'error');
} finally {
setCreateOppSubmitting(false);
}
};
// Remove from pipeline: archives (soft-deletes) the linked opportunity. The grid
// investor row — contacts, commitments, notes — is left fully intact.
const removeFromPipeline = async (row) => {
if (!row?.id) return;
const label = row.investor_name || 'this investor';
if (!window.confirm(`Remove ${label} from the pipeline? The deal is archived (recoverable); the grid row is untouched.`)) return;
try {
await api('/api/fundraising/pipeline/unlink', { method: 'POST', body: JSON.stringify({ source_row_id: row.id }) }, token);
setRows((prev) => prev.map((r) => (r.id === row.id ? { ...r, pipeline: false, pipeline_stage: '' } : r)));
onShowToast('Removed from the pipeline', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to remove from the pipeline'), 'error');
}
};
const addRow = () => {
const base = buildEmptyRow();
setRows((prev) => [base, ...prev]);
@@ -6109,10 +6132,11 @@
const latest = await api('/api/fundraising/state', {}, token);
expectedVersion = Number(latest?.data?.version);
}
const persistRows = stripComputedRows(nextRows);
const saveResponse = await api('/api/fundraising/state', {
method: 'PUT',
body: JSON.stringify({
grid: { columns: combinedColumns, rows: nextRows },
grid: { columns: combinedColumns, rows: persistRows },
views: nextViews,
expected_version: Number.isFinite(expectedVersion) && expectedVersion > 0 ? expectedVersion : 1
})
@@ -6121,8 +6145,8 @@
if (Number.isFinite(persistedVersion) && persistedVersion > 0) {
setRemoteVersion(persistedVersion);
}
lastSyncedSnapshotRef.current = JSON.stringify({ columns: combinedColumns, rows: nextRows, views: nextViews });
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: combinedColumns, rows: nextRows }));
lastSyncedSnapshotRef.current = JSON.stringify({ columns: combinedColumns, rows: persistRows, views: nextViews });
localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: combinedColumns, rows: persistRows }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(nextViews));
pushImportDebugEvent('import-persist-success', {
persisted_version: Number.isFinite(persistedVersion) ? persistedVersion : null,
@@ -6402,6 +6426,37 @@
if (typeof computed === 'number' && Number.isFinite(computed)) return computed.toLocaleString();
return String(computed ?? '');
}
if (col.id === 'pipeline') {
if (row.pipeline) {
return (
<button
type="button"
className="button-secondary"
style={{ padding: '5px 10px', fontSize: '12px', borderColor: '#2f6f4f', color: '#7fd3a3' }}
title="In the deal pipeline — click to remove (archives the deal; grid row stays)"
onClick={(e) => { e.stopPropagation(); removeFromPipeline(row); }}
>
✓ In pipeline
</button>
);
}
return (
<button
type="button"
className="button-secondary"
style={{ padding: '5px 10px', fontSize: '12px' }}
title="Add this investor to the deal pipeline"
onClick={(e) => { e.stopPropagation(); openCreateOpportunityModal(row); }}
>
+ Pipeline
</button>
);
}
if (col.id === 'pipeline_stage') {
const stage = String(row.pipeline_stage || '');
if (!stage) return <span style={{ color: '#70859b' }}></span>;
return <span style={{ textTransform: 'capitalize' }}>{stage.replace(/_/g, ' ')}</span>;
}
if (col.type === 'action' || col.id === 'log_action') {
return (
<button
@@ -7315,7 +7370,7 @@
{showCreateOppModal && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Create Pipeline Opportunity</div>
<div className="modal-header">Add to Pipeline</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
{createOppContext?.investorName || 'Investor'}
</div>
@@ -7357,13 +7412,18 @@
<input type="number" min="0" max="100" className="text-input" value={createOppForm.probability} onChange={(e) => setCreateOppForm((f) => ({ ...f, probability: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Fund Name (optional)</label>
<input className="text-input" value={createOppForm.fund_name} onChange={(e) => setCreateOppForm((f) => ({ ...f, fund_name: e.target.value }))} />
<label className="form-label">Fund (optional)</label>
<select className="select-input" value={createOppForm.fund_name} onChange={(e) => setCreateOppForm((f) => ({ ...f, fund_name: e.target.value }))}>
<option value="">— None —</option>
{columns.filter((c) => c.isFund).map((c) => (
<option key={c.id} value={c.label}>{c.label}</option>
))}
</select>
</div>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowCreateOppModal(false)}>Cancel</button>
<button type="button" onClick={submitCreateOpportunity} disabled={createOppSubmitting}>
{createOppSubmitting ? <Spinner /> : 'Create'}
{createOppSubmitting ? <Spinner /> : 'Add to Pipeline'}
</button>
</div>
</div>