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:
+134
-74
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user