Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { classifyTransition, WON_STAGES, LOST_STAGES } from './pipelineFunnelTransitions';
import {
ACTIVE_FUNNEL_STAGES,
CLOSED_FUNNEL_STAGES,
classifyTransition,
WON_STAGES,
LOST_STAGES,
} from './pipelineFunnelTransitions';

// Pipeline stage transition classifier β€” exercises the logic that was
// previously misbucketing wins as setbacks because terminal stages aren't
Expand Down Expand Up @@ -133,4 +139,58 @@ describe('classifyTransition', () => {
expect(warnSpy).not.toHaveBeenCalled();
});
});

// A5 Progress funnel split (mega-B, 2026-04-22): Collecting / In Effect was
// removed from ACTIVE_FUNNEL_STAGES and lives in CLOSED_FUNNEL_STAGES. The
// classifier still treats INTO-Collecting as 'won' (WON_STAGES unchanged),
// and adds an "unclose" guard so FROM-terminal BACK TO active reads as
// 'backward' (would otherwise misclassify as 'forward' because terminal
// stages aren't in STAGE_IDX β†’ fi=-1).
describe('A5 funnel split β€” terminal unclose regressions', () => {
it('classifies Collecting / In Effect β†’ Proposal Negotiation as backward (post-split)', () => {
// Was backward when Collecting was in STAGE_IDX (fi=7, ti=4). After the
// split, fi=-1 and the new guard catches this as a known-terminal
// regression.
expect(classifyTransition('Collecting / In Effect', 'Proposal Negotiation')).toBe('backward');
});

it('classifies Closed / Completed β†’ Qualifying as backward (win regression)', () => {
expect(classifyTransition('Closed / Completed', 'Qualifying')).toBe('backward');
});

it('classifies Closed Lost β†’ Proposal Negotiation as backward (loss regression)', () => {
expect(classifyTransition('Closed Lost', 'Proposal Negotiation')).toBe('backward');
});

it('classifies Withdrawn β†’ Contract Creation as backward (loss regression)', () => {
expect(classifyTransition('Withdrawn', 'Contract Creation')).toBe('backward');
});
});

describe('A5 funnel split β€” exports', () => {
it('ACTIVE_FUNNEL_STAGES has 7 stages and excludes Collecting / In Effect', () => {
expect(ACTIVE_FUNNEL_STAGES.length).toBe(7);
expect(ACTIVE_FUNNEL_STAGES.includes('Collecting / In Effect' as any)).toBe(false);
// Sanity: the 7 open stages we expect
expect(ACTIVE_FUNNEL_STAGES.includes('Lead Gen' as any)).toBe(true);
expect(ACTIVE_FUNNEL_STAGES.includes('Negotiating Contract' as any)).toBe(true);
});

it('CLOSED_FUNNEL_STAGES has 5 SF-canonical terminal stages in display order', () => {
expect(CLOSED_FUNNEL_STAGES).toEqual([
'Collecting / In Effect',
'Closed / Completed',
'Closed Lost',
'Withdrawn',
'Closed / Did not Fulfill',
]);
});

it('ACTIVE and CLOSED sets are disjoint (no stage in both)', () => {
const activeSet = new Set<string>(ACTIVE_FUNNEL_STAGES);
for (const stage of CLOSED_FUNNEL_STAGES) {
expect(activeSet.has(stage)).toBe(false);
}
});
});
});
107 changes: 107 additions & 0 deletions financial_forecasting/frontend/src/components/PipelineFunnel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import LookbackRangeSelector, {

import {
ACTIVE_FUNNEL_STAGES,
CLOSED_FUNNEL_STAGES,
STAGE_IDX,
WON_STAGES,
LOST_STAGES,
Expand Down Expand Up @@ -332,6 +333,31 @@ const PipelineFunnel: React.FC<PipelineFunnelProps> = ({ opportunities, selected
[opportunities, effectiveOwnerIds],
);

// Closed-stage opps β€” parallel to filteredOpps, same owner filter, but
// scoped to the terminal stages we render below the active funnel.
const closedOpps = useMemo(
() =>
opportunities.filter(
(opp) =>
CLOSED_FUNNEL_STAGES.includes(opp.StageName as any) &&
(!effectiveOwnerIds || (opp.OwnerId && effectiveOwnerIds.has(opp.OwnerId))),
),
[opportunities, effectiveOwnerIds],
);

const closedStageTotals = useMemo(() => {
const totals = new Map<string, { count: number; total: number }>();
for (const stage of CLOSED_FUNNEL_STAGES) totals.set(stage, { count: 0, total: 0 });
for (const opp of closedOpps) {
const entry = totals.get(opp.StageName);
if (entry) {
entry.count++;
entry.total += opp.Amount || 0;
}
}
return totals;
}, [closedOpps]);

const funnel = useMemo(() => buildFunnelData(filteredOpps, history), [filteredOpps, history]);

const maxCount = Math.max(...funnel.map((l) => l.count), 1);
Expand Down Expand Up @@ -707,6 +733,87 @@ const PipelineFunnel: React.FC<PipelineFunnelProps> = ({ opportunities, selected
})}
</Box>

{/* Closed section β€” terminal stages (wins + losses) rendered as compact
horizontal cards below the active funnel. These opps have exited
the active pipeline but are relevant to Progress-page reporting:
Collecting / In Effect (signed, payment in progress) through
Closed / Completed, and the three loss outcomes. */}
<Box sx={{ mt: 2, pt: 1.5, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography
variant="caption"
sx={{
fontWeight: 700,
color: 'text.secondary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
fontSize: '0.68rem',
display: 'block',
mb: 1,
}}
>
Closed
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{CLOSED_FUNNEL_STAGES.map((stage) => {
const entry = closedStageTotals.get(stage) || { count: 0, total: 0 };
const color = getStageHexColor(stage);
return (
<Box
key={stage}
sx={{
flex: '1 1 0',
minWidth: 140,
p: 1,
borderRadius: 1,
border: '1px solid',
borderColor: 'grey.200',
bgcolor: 'grey.50',
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: color,
flexShrink: 0,
}}
/>
<Typography
variant="caption"
noWrap
sx={{
fontSize: '0.72rem',
fontWeight: 600,
color: 'text.primary',
lineHeight: 1.2,
}}
>
{stage}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', pl: 1.75 }}>
<Tooltip title={`${entry.count} opportunit${entry.count === 1 ? 'y' : 'ies'} currently in ${stage}`} arrow>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: 'text.secondary', cursor: 'default' }}>
{entry.count} opp{entry.count !== 1 ? 's' : ''}
</Typography>
</Tooltip>
<Tooltip title={`Sum of Amount for ${entry.count} opp${entry.count !== 1 ? 's' : ''} in ${stage}`} arrow>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 700, cursor: 'default' }}>
{formatDollarMillions(entry.total)}
</Typography>
</Tooltip>
</Box>
</Box>
);
})}
</Box>
</Box>

<Box sx={{ mt: 2, pt: 1.5, borderTop: '1px solid', borderColor: 'divider' }}>
{!analysisData && !analysisLoading && (
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,69 @@ describe('InlineEditable β€” record-level lock by another user', () => {
expect(screen.getByDisplayValue('ACME Corp')).toBeInTheDocument();
});
});

describe('InlineEditable β€” per-row ownerGate', () => {
// Mirrors main.py:_enforce_record_ownership: non-admin / non-edit-all users
// may only edit records they own. Schema-generated cells pass ownerGate
// per-row; hand-coded cells omit it and fall through to sensitivity-only.
it('locks a safe field when the user is not the record owner (non-admin, no edit-all)', () => {
mockPermissions.sfUserId = 'user-1';
mockPermissions.isAdmin = false;
mockPermissions.can.mockReturnValue(false);
render(
<InlineEditable
{...safeProps}
value="ACME Corp"
ownerGate={{ rowOwnerId: 'other-user', editAllPermission: 'edit_all_opportunities' }}
/>,
);
fireEvent.click(screen.getByText('ACME Corp'));
// Should NOT enter edit mode β€” ownerGate blocks non-owners.
expect(screen.queryByDisplayValue('ACME Corp')).not.toBeInTheDocument();
});

it('allows edit when the user is admin, regardless of ownership', () => {
mockPermissions.sfUserId = 'user-1';
mockPermissions.isAdmin = true;
render(
<InlineEditable
{...safeProps}
value="ACME Corp"
ownerGate={{ rowOwnerId: 'other-user' }}
/>,
);
fireEvent.click(screen.getByText('ACME Corp'));
// Admin bypass: free edit on safe field.
expect(screen.getByDisplayValue('ACME Corp')).toBeInTheDocument();
});

it('allows edit when the user holds the edit-all permission for this resource', () => {
mockPermissions.sfUserId = 'user-1';
mockPermissions.isAdmin = false;
mockPermissions.can.mockImplementation((k: string) => k === 'edit_all_opportunities');
render(
<InlineEditable
{...safeProps}
value="ACME Corp"
ownerGate={{ rowOwnerId: 'other-user', editAllPermission: 'edit_all_opportunities' }}
/>,
);
fireEvent.click(screen.getByText('ACME Corp'));
expect(screen.getByDisplayValue('ACME Corp')).toBeInTheDocument();
});

it('allows edit when the user owns the row', () => {
mockPermissions.sfUserId = 'user-1';
mockPermissions.isAdmin = false;
mockPermissions.can.mockReturnValue(false);
render(
<InlineEditable
{...safeProps}
value="ACME Corp"
ownerGate={{ rowOwnerId: 'user-1' }}
/>,
);
fireEvent.click(screen.getByText('ACME Corp'));
expect(screen.getByDisplayValue('ACME Corp')).toBeInTheDocument();
});
});
Loading