Pipeline funnel v2: 4-stage enum + migration 0007 + derived grid signals
Collapse the inherited 6-stage opportunity funnel to the locked 4-stage
per-investor funnel (lead -> engaged -> diligence -> commitment), terminal at
commitment. Migration 0007 remaps existing stage values (outreach/meeting ->
engaged, due_diligence -> diligence, committed/funded -> commitment) and
archives the stray 'lost' value (the grid row is left intact). Inject read-only
existing_investor (total_invested>0), last_activity_at, and staleness
(''/'aging'>=30d/'stale'>=60d) into the grid GET, stripped on write. Frontend:
4-stage chip tints + Pipeline board / opp-form / mock on the new enum.
The visible desktop existing-investor star + staleness recency column + the
Stale saved view are deferred to mobile Phase 3 (data is injected + test-locked
now, so that phase stays pure-frontend). Longshot was already retired by prior
cleanup -- no-op.
Tests: test_pipeline_stages_v2.py (migration remap + derivation boundaries) +
updated grid-pipeline-link / soft-delete / nl_query; 36/36 green, render-smoke
green, fresh-DB migrate clean.
This commit is contained in:
+40
-16
@@ -1900,6 +1900,20 @@
|
||||
return err.message || err.error || fallback;
|
||||
};
|
||||
|
||||
// 4-stage per-investor funnel, terminal at commitment (locked spec 2026-06-19; mirrors
|
||||
// server.PIPELINE_STAGES). Labels + chip tints reuse existing semantic colors per
|
||||
// design/DESIGN.md §2: lead = subtle grey, engaged = accent blue, diligence = due-soon
|
||||
// amber, commitment = success green — tinted-fill + tinted-text badges (no new hues).
|
||||
const PIPELINE_STAGES = ['lead', 'engaged', 'diligence', 'commitment'];
|
||||
const PIPELINE_STAGE_LABELS = { lead: 'Lead', engaged: 'Engaged', diligence: 'Diligence', commitment: 'Commitment' };
|
||||
const pipelineStageLabel = (stage) => PIPELINE_STAGE_LABELS[stage] || (stage ? String(stage).replace(/_/g, ' ') : '');
|
||||
const PIPELINE_STAGE_CHIP = {
|
||||
lead: { color: '#8ea2b7', border: '#3a4a5e' },
|
||||
engaged: { color: '#93c5fd', border: '#2f5170' },
|
||||
diligence: { color: '#e0b341', border: '#7a6320' },
|
||||
commitment: { color: '#6ee7b7', border: '#2f6f4f' },
|
||||
};
|
||||
|
||||
const contactName = (row) => {
|
||||
if (!row) return '-';
|
||||
if (row.contact_name) return row.contact_name;
|
||||
@@ -2039,9 +2053,9 @@
|
||||
{ id: 'c-1004', first_name: 'Jennifer', last_name: 'Taylor', email: 'jtaylor@blueharbor.org', phone: '555-1004', title: 'Executive Director', organization: 'Blue Harbor Foundation', organization_name: 'Blue Harbor Foundation', contact_type: 'prospect', status: 'active', last_contact_date: '2026-02-09T08:30:00Z', communication_count: 1, comm_count: 1 }
|
||||
],
|
||||
opportunities: [
|
||||
{ id: 'o-2001', name: 'Cascade - Fund II', contact_id: 'c-1003', contact_name: 'David Martinez', stage: 'meeting', expected_amount: 10000000, commitment_amount: 0, probability: 35, priority: 'high', fund_name: 'Fund II', organization_name: 'Cascade Wealth Management', updated_at: '2026-02-12T10:00:00Z' },
|
||||
{ id: 'o-2002', name: 'Blue Harbor - Fund II', contact_id: 'c-1004', contact_name: 'Jennifer Taylor', stage: 'due_diligence', expected_amount: 5000000, commitment_amount: 0, probability: 60, priority: 'medium', fund_name: 'Fund II', organization_name: 'Blue Harbor Foundation', updated_at: '2026-02-11T10:00:00Z' },
|
||||
{ id: 'o-2003', name: 'Sovereign Re-up', contact_id: 'c-1001', contact_name: 'James Chen', stage: 'committed', expected_amount: 25000000, commitment_amount: 10000000, probability: 85, priority: 'high', fund_name: 'Fund III', organization_name: 'Sovereign Wealth Holdings', updated_at: '2026-02-10T10:00:00Z' }
|
||||
{ id: 'o-2001', name: 'Cascade - Fund II', contact_id: 'c-1003', contact_name: 'David Martinez', stage: 'engaged', expected_amount: 10000000, commitment_amount: 0, probability: 35, priority: 'high', fund_name: 'Fund II', organization_name: 'Cascade Wealth Management', updated_at: '2026-02-12T10:00:00Z' },
|
||||
{ id: 'o-2002', name: 'Blue Harbor - Fund II', contact_id: 'c-1004', contact_name: 'Jennifer Taylor', stage: 'diligence', expected_amount: 5000000, commitment_amount: 0, probability: 60, priority: 'medium', fund_name: 'Fund II', organization_name: 'Blue Harbor Foundation', updated_at: '2026-02-11T10:00:00Z' },
|
||||
{ id: 'o-2003', name: 'Sovereign Re-up', contact_id: 'c-1001', contact_name: 'James Chen', stage: 'commitment', expected_amount: 25000000, commitment_amount: 10000000, probability: 85, priority: 'high', fund_name: 'Fund III', organization_name: 'Sovereign Wealth Holdings', updated_at: '2026-02-10T10:00:00Z' }
|
||||
],
|
||||
communications: [
|
||||
{ id: 'm-3001', contact_id: 'c-1001', contact_name: 'James Chen', type: 'meeting', subject: 'Q1 Strategy Review', body: 'Discussed deployment pace.', communication_date: '2026-02-12T15:00:00Z', outcome: 'positive' },
|
||||
@@ -2175,11 +2189,11 @@
|
||||
total_lps: mockDb.contacts.filter((c) => c.contact_type === 'investor').length,
|
||||
total_prospects: mockDb.contacts.filter((c) => c.contact_type === 'prospect').length,
|
||||
total_committed: mockDb.lp_profiles.reduce((s, lp) => s + (lp.commitment_amount || 0), 0),
|
||||
pipeline_value: mockDb.opportunities.filter((o) => !['funded', 'lost'].includes(o.stage)).reduce((s, o) => s + (o.expected_amount || 0), 0),
|
||||
active_opportunities: mockDb.opportunities.filter((o) => !['funded', 'lost'].includes(o.stage)).length,
|
||||
pipeline_value: mockDb.opportunities.filter((o) => o.stage !== 'commitment').reduce((s, o) => s + (o.expected_amount || 0), 0),
|
||||
active_opportunities: mockDb.opportunities.filter((o) => o.stage !== 'commitment').length,
|
||||
comms_this_month: mockDb.communications.length
|
||||
};
|
||||
const stages = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded']
|
||||
const stages = PIPELINE_STAGES
|
||||
.map((stage) => {
|
||||
const rows = mockDb.opportunities.filter((o) => o.stage === stage);
|
||||
return { stage, count: rows.length, total_value: rows.reduce((s, r) => s + (r.expected_amount || 0), 0) };
|
||||
@@ -4173,7 +4187,7 @@
|
||||
const [selectedOpp, setSelectedOpp] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
|
||||
const stages = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded'];
|
||||
const stages = PIPELINE_STAGES;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOpportunities = async () => {
|
||||
@@ -4259,7 +4273,7 @@
|
||||
<div className="pipeline-summary">
|
||||
{stages.map(stage => (
|
||||
<div key={stage} className="pipeline-stage-card">
|
||||
<div className="pipeline-stage-name">{stage.replace('_', ' ')}</div>
|
||||
<div className="pipeline-stage-name">{pipelineStageLabel(stage)}</div>
|
||||
<div className="pipeline-stage-count">{opportunitiesByStage[stage].length}</div>
|
||||
<div className="pipeline-stage-amount">{formatCurrencyLong(stageTotals[stage])}</div>
|
||||
</div>
|
||||
@@ -4269,7 +4283,7 @@
|
||||
<div className="kanban-board">
|
||||
{stages.map(stage => (
|
||||
<div key={stage} className="kanban-column">
|
||||
<div className="kanban-header">{stage.replace(/_/g, ' ')}</div>
|
||||
<div className="kanban-header">{pipelineStageLabel(stage)}</div>
|
||||
{opportunitiesByStage[stage].map(opp => (
|
||||
<div
|
||||
key={opp.id}
|
||||
@@ -5271,7 +5285,12 @@
|
||||
// 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, reminder_status, ...rest } = r;
|
||||
// existing_investor / last_activity_at / staleness are server-derived read-only
|
||||
// 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,
|
||||
existing_investor, last_activity_at, staleness, ...rest } = r;
|
||||
return rest;
|
||||
}) : rs);
|
||||
|
||||
@@ -6719,7 +6738,14 @@
|
||||
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>;
|
||||
const sc = PIPELINE_STAGE_CHIP[stage] || { color: '#8ea2b7', border: '#3a4a5e' };
|
||||
return (
|
||||
<span style={{ display: 'inline-block', padding: '2px 8px', borderRadius: '4px',
|
||||
fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
color: sc.color, border: `1px solid ${sc.border}`, backgroundColor: sc.color + '1a' }}>
|
||||
{pipelineStageLabel(stage)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (col.id === 'reminder_status') {
|
||||
const rs = String(row.reminder_status || '');
|
||||
@@ -7735,11 +7761,9 @@
|
||||
<label className="form-label">Stage</label>
|
||||
<select className="select-input" value={createOppForm.stage} onChange={(e) => setCreateOppForm((f) => ({ ...f, stage: e.target.value }))}>
|
||||
<option value="lead">Lead</option>
|
||||
<option value="outreach">Outreach</option>
|
||||
<option value="meeting">Meeting</option>
|
||||
<option value="due_diligence">Due Diligence</option>
|
||||
<option value="committed">Committed</option>
|
||||
<option value="funded">Funded</option>
|
||||
<option value="engaged">Engaged</option>
|
||||
<option value="diligence">Diligence</option>
|
||||
<option value="commitment">Commitment</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
|
||||
Reference in New Issue
Block a user